Skip to content

Keep the exception recorded by an error handler on request.completed - #36

Merged
anth-volk merged 2 commits into
mainfrom
fix/keep-recorded-request-error
Oct 20, 2026
Merged

anth-volk merged 2 commits into
mainfrom
fix/keep-recorded-request-error

Conversation

@anth-volk

Copy link
Copy Markdown
Contributor

Fixes #35

Summary

ObservabilityRuntime.end_request assigned its error argument to the request state unconditionally, which replaced an exception attached by record_exception with None. The Flask adapter ends each request from its teardown_request callback, and Flask passes None to that callback once a registered error handler has handled the exception. A Flask error handler that called record_exception(error, handled=False, status_code=500) and returned a 500 response therefore produced a request.completed record with outcome="error" but no error.type, error.message or error.stack, and the server span was ended without the exception event. The Public API (PolicyEngine/policyengine-api#3984) found this when its catch-all Flask error handler started recording exceptions.

Change

end_request still computes the outcome from its error argument and the resolved status code, exactly as before. When that outcome is "error" and no error argument was passed, it now uses the exception attached by record_exception for the request.completed error fields and for ending the span. The change is in policyengine_observability/runtime.py; the Flask adapter is unchanged.

The same applies to a FastAPI exception handler registered for a specific exception type that records the exception and returns a 500 response. Starlette runs those handlers inside the observability middleware, so the adapter only receives the response in that case and previously dropped the recorded exception in the same way.

Behavior that does not change

  • The outcome calculation is unchanged. The attached exception is never used to compute it.
  • A request that records a handled exception (record_exception(..., handled=True)) and returns a 2xx or 4xx response keeps outcome="success" or outcome="client_error", INFO severity and no error.* fields, and adds no request point to policyengine.error.count. Using the attached exception unconditionally would have turned these requests into errors.
  • An error argument passed to end_request still takes precedence over an attached exception.
  • The FastAPI adapter still passes an exception that propagates out of the application to end_request directly, and that exception is reported.
  • record_exception still emits its own exception.recorded record and error metric point.

Tests

New tests, written before the fix:

  • test_flask_error_handler_exception_is_kept_on_request_completion: a Flask error handler records the exception with handled=False, status_code=500 and returns a 500 response. Exactly one request.completed record is emitted, with outcome="error", ERROR severity and the exception's error.type, error.message and error.stack; the error metric receives one unhandled point and one request point; the span has error status and one exception event.
  • test_flask_handled_exception_does_not_mark_request_failed, for a 200 and a 404 response: outcome="success" or "client_error", INFO severity, no error.* fields, only the handled error metric point, and a span with unset status and no events.
  • test_server_error_completion_uses_recorded_exception: the runtime-level case, end_request(status_code=None) after record_exception(..., status_code=500).
  • test_explicit_request_error_takes_precedence_over_recorded_exception and test_fastapi_raised_exception_takes_precedence_over_recorded_error: an exception passed to end_request is reported instead of an earlier recorded one.
  • test_fastapi_handled_exception_does_not_mark_request_failed: a FastAPI route that records a handled exception and returns 200 keeps outcome="success".
  • test_fastapi_exception_handler_error_is_kept_on_request_completion: a FastAPI exception handler that records the exception and returns 500.

Before the fix, the three tests that check for error fields on a 5xx completion failed with KeyError: 'error.type', and the other five passed. With a version of the fix that used the attached exception unconditionally, all three handled-exception tests failed with outcome == "error".

Verification

  • uv run --extra dev ruff format --check .: 32 files already formatted
  • uv run --extra dev ruff check .: all checks passed
  • uv run --extra dev --extra all coverage run -m pytest: 148 passed
  • uv run --extra dev --extra all coverage report: 91% total, above the 90% minimum
  • uv run --extra dev --extra all pyright: 0 errors
  • towncrier check --compare-with origin/main: finds changelog.d/35.fixed.md

🤖 Generated with Claude Code

anth-volk and others added 2 commits October 20, 2026 22:27
ObservabilityRuntime.end_request assigned its error argument to the
request state unconditionally, which replaced an exception attached by
record_exception with None. The Flask adapter ends requests from its
teardown_request callback, and Flask passes None to that callback after
a registered error handler has handled the exception. A Flask error
handler that recorded the exception and returned a 500 response
therefore produced a request.completed record without error.type,
error.message or error.stack, and ended the server span without the
exception event.

end_request still computes the outcome from its error argument and the
resolved status code. When that outcome is "error" and no error
argument was passed, it now uses the recorded exception for the
completion record's error fields and for ending the span. Requests that
record a handled exception and end with a 2xx or 4xx response keep
their outcome, INFO severity and absence of error fields, and add no
request point to policyengine.error.count. An error argument passed to
end_request still takes precedence over a recorded exception.

The same change applies to a FastAPI exception handler that records the
exception and returns a 500 response, because the FastAPI adapter only
receives the response in that case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk marked this pull request as ready for review October 20, 2026 18:45
@anth-volk
anth-volk merged commit 04e8848 into main Oct 20, 2026
3 checks passed
@anth-volk
anth-volk deleted the fix/keep-recorded-request-error branch October 20, 2026 18:45
anth-volk added a commit to PolicyEngine/policyengine-api that referenced this pull request Oct 20, 2026
…r exception

In 3.0.2, Flask's teardown callback received no exception once
response_generic_error had handled it, and end_request discarded the
exception that record_unhandled_exception attached. The request's
request.completed record then had no error fields. 3.0.3
(PolicyEngine/policyengine-observability#36) keeps the attached exception
when the request has already failed. Requests that record a handled
exception and still succeed keep their outcome.

A new test checks that request.completed for an unhandled exception
carries the exception's type, message and stack.

uv.lock was regenerated with uv 0.12.1, the version CI uses, so its
format revision is unchanged. The changelog fragment and
gcp/observability/README.md now name 3.0.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Keep the exception recorded by a Flask error handler on request.completed

1 participant