aviramha: Should I add a test that checks in case a response started that the handler is still called?
aviramha: @tomchristie Thoughts?
florimondmanca: @aviramha I'm not sure I understand the use case here. Can you clarify why we'd need to move the call to the installed error handler separately from the other cases?
aviramha: > @aviramha I'm not sure I understand the use case here. Can you clarify why we'd want/need to move the call to the installed error handler separately from the other cases?

The use case are exception handlers not being called on background tasks since the response already started. This changes the logic so the handlers will be called even if response is returned. It's extremely important for usage of error loggers like sentry etc. 
aviramha: Any update? :)
Kludex: I'll wait to see if someone else wants to comment here.
adriangb: I think this makes sense, for example if users log within their exception handlers.

My main two concerns would be:

- How this interacts with the work to get exception handlers for websockets
- We'll have to document the behavior. I could see users getting confused as to why the response they return from error handlers isn't used
Kludex: > How this interacts with the work to get exception handlers for websockets

This is only for HTTP, no interaction.


Kludex: Docs added. Suggestions welcome about the wording, if any.
aviramha: Looks great. Thanks @Kludex for seeing this through.
Kludex: @tomchristie Would you like to get the last :+1: here?
diff --git a/docs/exceptions.md b/docs/exceptions.md
index bf460d2..9818a20 100644
--- a/docs/exceptions.md
+++ b/docs/exceptions.md
@@ -75,6 +75,24 @@ should bubble through the entire middleware stack as exceptions. Any error
 logging middleware should ensure that it re-raises the exception all the
 way up to the server.
 
+In practical terms, the error handled used is `exception_handler[500]` or `exception_handler[Exception]`.
+Both keys `500` and `Exception` can be used. See below:
+
+```python
+async def handle_error(request: Request, exc: HTTPException):
+    # Perform some logic
+    return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
+
+exception_handlers = {
+    Exception: handle_error  # or "500: handle_error"
+}
+```
+
+It's important to notice that in case a [`BackgroundTask`](https://www.starlette.io/background/) raises an exception,
+it will be handled by the `handle_error` function, but at that point, the response was already sent. In other words,
+the response created by `handle_error` will be discarded. In case the error happens before the response was sent, then
+it will use the response object - in the above example, the returned `JSONResponse`.
+
 In order to deal with this behaviour correctly, the middleware stack of a
 `Starlette` application is configured like this:
 
diff --git a/starlette/middleware/errors.py b/starlette/middleware/errors.py
index 30f5570..474c9af 100644
--- a/starlette/middleware/errors.py
+++ b/starlette/middleware/errors.py
@@ -158,21 +158,21 @@ class ServerErrorMiddleware:
         try:
             await self.app(scope, receive, _send)
         except Exception as exc:
-            if not response_started:
-                request = Request(scope)
-                if self.debug:
-                    # In debug mode, return traceback responses.
-                    response = self.debug_response(request, exc)
-                elif self.handler is None:
-                    # Use our default 500 error handler.
-                    response = self.error_response(request, exc)
+            request = Request(scope)
+            if self.debug:
+                # In debug mode, return traceback responses.
+                response = self.debug_response(request, exc)
+            elif self.handler is None:
+                # Use our default 500 error handler.
+                response = self.error_response(request, exc)
+            else:
+                # Use an installed 500 error handler.
+                if asyncio.iscoroutinefunction(self.handler):
+                    response = await self.handler(request, exc)
                 else:
-                    # Use an installed 500 error handler.
-                    if asyncio.iscoroutinefunction(self.handler):
-                        response = await self.handler(request, exc)
-                    else:
-                        response = await run_in_threadpool(self.handler, request, exc)
+                    response = await run_in_threadpool(self.handler, request, exc)
 
+            if not response_started:
                 await response(scope, receive, send)
 
             # We always continue to raise the exception.

Background tasks exception handler
Currently, if a background task faces an exception, it isn't handled and there's no way to handle it (rather than wrapping the task)
My suggestion is to be able to add exception handler for background tasks, as there is for requests.
diff --git a/tests/middleware/test_errors.py b/tests/middleware/test_errors.py
index 2c926a9..392c2ba 100644
--- a/tests/middleware/test_errors.py
+++ b/tests/middleware/test_errors.py
@@ -1,7 +1,10 @@
 import pytest
 
+from starlette.applications import Starlette
+from starlette.background import BackgroundTask
 from starlette.middleware.errors import ServerErrorMiddleware
 from starlette.responses import JSONResponse, Response
+from starlette.routing import Route
 
 
 def test_handler(test_client_factory):
@@ -68,3 +71,28 @@ def test_debug_not_http(test_client_factory):
         client = test_client_factory(app)
         with client.websocket_connect("/"):
             pass  # pragma: nocover
+
+
+def test_background_task(test_client_factory):
+    accessed_error_handler = False
+
+    def error_handler(request, exc):
+        nonlocal accessed_error_handler
+        accessed_error_handler = True
+
+    def raise_exception():
+        raise Exception("Something went wrong")
+
+    async def endpoint(request):
+        task = BackgroundTask(raise_exception)
+        return Response(status_code=204, background=task)
+
+    app = Starlette(
+        routes=[Route("/", endpoint=endpoint)],
+        exception_handlers={Exception: error_handler},
+    )
+
+    client = test_client_factory(app, raise_server_exceptions=False)
+    response = client.get("/")
+    assert response.status_code == 204
+    assert accessed_error_handler

pip install -e .[full]
parse_log_pytest
requirements.txt
pytest
3.7
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
aiosqlite==0.19.0
anyio==3.7.1
async-generator==1.10
attrs==24.2.0
autoflake==1.4
black==21.12b0
bleach==6.0.0
certifi @ file:///croot/certifi_1671487769961/work/certifi
cffi==1.15.1
charset-normalizer==3.4.1
click==8.1.8
colorama==0.4.6
coverage==6.2
cryptography==44.0.2
databases==0.5.3
docutils==0.20.1
exceptiongroup==1.2.2
flake8==4.0.1
ghp-import==2.1.0
greenlet==3.1.1
idna==3.10
importlib-metadata==4.2.0
iniconfig==2.0.0
isort==5.10.1
itsdangerous==2.1.2
jaraco.classes==3.2.3
jeepney==0.9.0
Jinja2==3.1.6
keyring==23.9.3
Markdown==3.3.4
MarkupSafe==2.1.5
mccabe==0.6.1
mergedeep==1.3.4
mkautodoc==0.1.0
mkdocs==1.2.3
mkdocs-material==8.1.3
mkdocs-material-extensions==1.2
more-itertools==9.1.0
mypy==0.931
mypy-extensions==1.0.0
outcome==1.3.0.post0
packaging==24.0
pathspec==0.11.2
pkginfo==1.10.0
platformdirs==4.0.0
pluggy==1.2.0
py==1.11.0
pycodestyle==2.8.0
pycparser==2.21
pyflakes==2.4.0
Pygments==2.17.2
pymdown-extensions==10.2.1
pytest==6.2.5
python-dateutil==2.9.0.post0
python-multipart==0.0.8
PyYAML==6.0.1
pyyaml_env_tag==0.1
readme-renderer==37.3
requests==2.31.0
requests-toolbelt==1.0.0
rfc3986==2.0.0
SecretStorage==3.3.3
six==1.17.0
sniffio==1.3.1
sortedcontainers==2.4.0
SQLAlchemy==1.4.54
-e git+https://github.com/encode/starlette.git@6182d0a0bc7e5817197d2919b18d67f70e3a71d1#egg=starlette
toml==0.10.2
tomli==1.2.3
tqdm==4.67.1
trio==0.19.0
twine==3.7.1
typed-ast==1.5.5
types-contextvars==2.4.0
types-dataclasses==0.6.2
types-PyYAML==6.0.1
types-requests==2.26.3
typing_extensions==4.7.1
urllib3==2.0.7
watchdog==3.0.0
webencodings==0.5.1
zipp==3.15.0

name: starlette
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2022.12.7=py37h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=22.3.1=py37h06a4308_0
  - python=3.7.16=h7a1cb2a_0
  - readline=8.2=h5eee18b_0
  - setuptools=65.6.3=py37h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - aiosqlite==0.19.0
      - anyio==3.7.1
      - async-generator==1.10
      - attrs==24.2.0
      - autoflake==1.4
      - black==21.12b0
      - bleach==6.0.0
      - cffi==1.15.1
      - charset-normalizer==3.4.1
      - click==8.1.8
      - colorama==0.4.6
      - coverage==6.2
      - cryptography==44.0.2
      - databases==0.5.3
      - docutils==0.20.1
      - exceptiongroup==1.2.2
      - flake8==4.0.1
      - ghp-import==2.1.0
      - greenlet==3.1.1
      - idna==3.10
      - importlib-metadata==4.2.0
      - iniconfig==2.0.0
      - isort==5.10.1
      - itsdangerous==2.1.2
      - jaraco-classes==3.2.3
      - jeepney==0.9.0
      - jinja2==3.1.6
      - keyring==23.9.3
      - markdown==3.3.4
      - markupsafe==2.1.5
      - mccabe==0.6.1
      - mergedeep==1.3.4
      - mkautodoc==0.1.0
      - mkdocs==1.2.3
      - mkdocs-material==8.1.3
      - mkdocs-material-extensions==1.2
      - more-itertools==9.1.0
      - mypy==0.931
      - mypy-extensions==1.0.0
      - outcome==1.3.0.post0
      - packaging==24.0
      - pathspec==0.11.2
      - pkginfo==1.10.0
      - platformdirs==4.0.0
      - pluggy==1.2.0
      - py==1.11.0
      - pycodestyle==2.8.0
      - pycparser==2.21
      - pyflakes==2.4.0
      - pygments==2.17.2
      - pymdown-extensions==10.2.1
      - pytest==6.2.5
      - python-dateutil==2.9.0.post0
      - python-multipart==0.0.8
      - pyyaml==6.0.1
      - pyyaml-env-tag==0.1
      - readme-renderer==37.3
      - requests==2.31.0
      - requests-toolbelt==1.0.0
      - rfc3986==2.0.0
      - secretstorage==3.3.3
      - six==1.17.0
      - sniffio==1.3.1
      - sortedcontainers==2.4.0
      - sqlalchemy==1.4.54
      - toml==0.10.2
      - tomli==1.2.3
      - tqdm==4.67.1
      - trio==0.19.0
      - twine==3.7.1
      - typed-ast==1.5.5
      - types-contextvars==2.4.0
      - types-dataclasses==0.6.2
      - types-pyyaml==6.0.1
      - types-requests==2.26.3
      - typing-extensions==4.7.1
      - urllib3==2.0.7
      - watchdog==3.0.0
      - webencodings==0.5.1
      - wheel==0.37.1
      - zipp==3.15.0
prefix: /opt/conda/envs/starlette

tests/middleware/test_errors.py::test_background_task[asyncio] tests/middleware/test_errors.py::test_background_task[trio]
tests/middleware/test_errors.py::test_handler[asyncio] tests/middleware/test_errors.py::test_handler[trio] tests/middleware/test_errors.py::test_debug_text[asyncio] tests/middleware/test_errors.py::test_debug_text[trio] tests/middleware/test_errors.py::test_debug_html[asyncio] tests/middleware/test_errors.py::test_debug_html[trio] tests/middleware/test_errors.py::test_debug_after_response_sent[asyncio] tests/middleware/test_errors.py::test_debug_after_response_sent[trio] tests/middleware/test_errors.py::test_debug_not_http[asyncio] tests/middleware/test_errors.py::test_debug_not_http[trio]
docs/exceptions.md starlette/middleware/errors.py
docs/exceptions.md starlette/middleware/errors.py