Repository navigation
Keep the exception recorded by an error handler on request.completed - #36
Merged
Merged
Conversation
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
marked this pull request as ready for review
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #35
Summary
ObservabilityRuntime.end_requestassigned itserrorargument to the request state unconditionally, which replaced an exception attached byrecord_exceptionwithNone. The Flask adapter ends each request from itsteardown_requestcallback, and Flask passesNoneto that callback once a registered error handler has handled the exception. A Flask error handler that calledrecord_exception(error, handled=False, status_code=500)and returned a 500 response therefore produced arequest.completedrecord withoutcome="error"but noerror.type,error.messageorerror.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_requeststill computes the outcome from itserrorargument and the resolved status code, exactly as before. When that outcome is"error"and noerrorargument was passed, it now uses the exception attached byrecord_exceptionfor therequest.completederror fields and for ending the span. The change is inpolicyengine_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
record_exception(..., handled=True)) and returns a 2xx or 4xx response keepsoutcome="success"oroutcome="client_error", INFO severity and noerror.*fields, and adds no request point topolicyengine.error.count. Using the attached exception unconditionally would have turned these requests into errors.errorargument passed toend_requeststill takes precedence over an attached exception.end_requestdirectly, and that exception is reported.record_exceptionstill emits its ownexception.recordedrecord 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 withhandled=False, status_code=500and returns a 500 response. Exactly onerequest.completedrecord is emitted, withoutcome="error", ERROR severity and the exception'serror.type,error.messageanderror.stack; the error metric receives oneunhandledpoint and onerequestpoint; the span has error status and oneexceptionevent.test_flask_handled_exception_does_not_mark_request_failed, for a 200 and a 404 response:outcome="success"or"client_error", INFO severity, noerror.*fields, only thehandlederror 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)afterrecord_exception(..., status_code=500).test_explicit_request_error_takes_precedence_over_recorded_exceptionandtest_fastapi_raised_exception_takes_precedence_over_recorded_error: an exception passed toend_requestis 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 keepsoutcome="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 withoutcome == "error".Verification
uv run --extra dev ruff format --check .: 32 files already formatteduv run --extra dev ruff check .: all checks passeduv run --extra dev --extra all coverage run -m pytest: 148 passeduv run --extra dev --extra all coverage report: 91% total, above the 90% minimumuv run --extra dev --extra all pyright: 0 errorstowncrier check --compare-with origin/main: findschangelog.d/35.fixed.md🤖 Generated with Claude Code