Why is my Python error reporting missing errors, doubling them or leaking secrets?
Five mistakes come up again and again in Python error reporting. Copying request headers sends Authorization and Cookie values to your tracker. Flask's errorhandler(Exception) also catches 404s and can turn them into 500s. A framework hook and a logging handler each report the same exception. Patching Thread.run misses every subclass that overrides run. And a dropped ThreadPoolExecutor future reports nothing at all. We hit the first four in Runheed's Python SDK, running it in real FastAPI, Flask and Django apps with fake secrets, after unit tests had passed.
Trap 1: copying request headers leaks secrets
Adding request context is the obvious next step, and dict(request.headers) is the obvious way to do it. In Starlette, FastAPI, Flask and Django that dict holds the caller's real Authorization and Cookie values, and query strings can hold tokens too. They then sit in your tracker in plain text. Redact by key name before anything leaves the process:
import re
SENSITIVE = re.compile(
r"^(.*[-_])?(authorization|cookie|cookies|set-cookie|token|secret|password|api[-_]?key|session|csrf|xsrf)$",
re.IGNORECASE,
)
def redact_headers(headers):
return {k: "[REDACTED]" if SENSITIVE.match(k) else v for k, v in dict(headers).items()}
def scrub_url(url):
return re.sub(
r"([?&])([^=&#]+)=([^&#]*)",
lambda m: f"{m.group(1)}{m.group(2)}=[REDACTED]" if SENSITIVE.match(m.group(2)) else m.group(0),
str(url),
)Match the whole word at the end of the key, so token and x-api-key match but token_count doesn't. To test it, send a request with fake values such as Authorization: Bearer SECRETAUTH, then search the captured payload for SECRET. Do the same redaction again on the server if you run one, so an old client can't undo it.
Trap 2: Flask errorhandler(Exception) catches your 404s
In Flask, a handler registered for Exception also receives HTTPException, which covers 404s and abort(). Flask's docs warn about this. If the handler reports and re-raises, a plain 404 becomes a reported 500. Listen to the signal instead, which only fires for real unhandled exceptions:
from flask import Flask, got_request_exception
app = Flask(__name__)
def report(sender, exception, **extra):
capture_exception(exception)
got_request_exception.connect(report, app)Trap 3: the hook and the logger both report it
Django logs a failed request through the django.request logger with the exception attached, and Flask logs through app.logger. So if you have a framework hook and a logging handler that reports records with exc_info, each error arrives twice and your counts double. Mark the exception the first time you see it:
def capture_exception(exc):
if getattr(exc, "__reported__", False):
return
try:
exc.__reported__ = True
except (AttributeError, TypeError):
pass
send(exc)Trap 4: patching Thread.run misses subclasses
A wrapper around threading.Thread.run never runs for a subclass that overrides run(), which is the usual way to write a worker. Since Python 3.8 there is a proper hook. Chain to the previous one so the traceback still prints, and skip SystemExit, which Python passes to the hook too and which is how a thread ends on purpose:
import threading
previous = threading.excepthook
def hook(args):
if args.exc_value is not None and not issubclass(args.exc_type, SystemExit):
capture_exception(args.exc_value)
previous(args)
threading.excepthook = hookTrap 5: some failures make no noise at all
We ran each of these on Python 3.12 against a mock server, with sys.excepthook, threading.excepthook and a logging handler in place:
| Failure | What Python does | How a reporter sees it |
|---|---|---|
| Uncaught exception in the main thread | Prints a traceback, exit code 1 | sys.excepthook |
| asyncio.run(main()) raises | Prints a traceback, exit code 1 | sys.excepthook |
| A create_task whose result nobody reads | Logs "Task exception was never retrieved" when the task is garbage collected, exit code 0 | The logging handler, and only then |
| A throw in a thread | Prints a traceback, the process carries on | threading.excepthook |
| A ThreadPoolExecutor future nobody reads | Nothing. The exception stays on the future, exit code 0 | Not at all, unless you call result() or add a callback |
The last row is the one to fix in your own code: keep the future and check it, or attach a callback.
def check(future):
exc = future.exception()
if exc is not None:
capture_exception(exc)
executor.submit(work).add_done_callback(check)How to catch these before your users do
- Run your setup code in a real app for each framework you support, not just in unit tests.
- Send fake secrets in the headers and query string, then search the captured payload for them.
- Request a route that doesn't exist and check that no error is reported.
- Throw one error and check that you get exactly one report.
Runheed's Python SDK ships these fixes. Setup pages: /for/fastapi, /for/django and /for/flask.
Questions
- Does sys.excepthook catch exceptions in threads?
- No. It covers the main thread. Threads go through threading.excepthook, added in Python 3.8.
- Does the logging handler report an error logged without an exception?
- That depends on the handler. Runheed's records logger.error calls with no exception as breadcrumbs and reports records that carry exc_info, such as logger.exception.