DEV Community

Great Work
Great Work

Posted on Originally published at runheed.com

Why is my Python error reporting missing errors, doubling them or leaking secrets?

Originally published at runheed.com.

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:

redact.py

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),
    )
Enter fullscreen mode Exit fullscreen mode

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:

app.py

from flask import Flask, got_request_exception

app = Flask(__name__)

def report(sender, exception, **extra):
    capture_exception(exception)

got_request_exception.connect(report, app)
Enter fullscreen mode Exit fullscreen mode

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:

once.py

def capture_exception(exc):
    if getattr(exc, "__reported__", False):
        return
    try:
        exc.__reported__ = True
    except (AttributeError, TypeError):
        pass
    send(exc)
Enter fullscreen mode Exit fullscreen mode

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:

threads.py

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 = hook
Enter fullscreen mode Exit fullscreen mode

Trap 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.

pool.py

def check(future):
    exc = future.exception()
    if exc is not None:
        capture_exception(exc)

executor.submit(work).add_done_callback(check)
Enter fullscreen mode Exit fullscreen mode

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.

FAQ

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.

Related

Top comments (0)