Repository navigation
Stop recording exceptions for gateway client errors - #963
Merged
Merged
Conversation
The gateway answers some invalid requests with a 400 or 404 and also
calls record_exception for them, which writes an ERROR record although
the request's request.completed record already reports the response as
outcome client_error.
These tests assert that no exception is recorded for:
- an unknown country (the deploy authentication test's
__authentication_probe__) on POST /simulate/economy/comparison and on
POST /simulate/economy/budget-window;
- a ValueError from building the bundle description on either
submission endpoint;
- an unknown job ID on GET /jobs/{job_id};
- an unknown batch ID on GET /budget-window-jobs/{batch_job_id};
- an unknown kind on GET /versions/{kind}.
Each still expects the same status code and response body. A new test
checks that a server failure (Modal's FunctionCall lookup raising during
GET /jobs/{job_id}, answered with 500) still records its exception as
unhandled with status 500; it passes before and after the fix.
The three existing tests for the 404 responses asserted the recorded
LookupError, and now assert that nothing is recorded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ObservabilityRuntime.record_exception always writes an ERROR record, and
the daily error report counts every ERROR record. The gateway called it
for requests it then answered with a client error, although each
request's request.completed record already reports the response with
outcome client_error. Most of these records came from the deploy
pipeline's authentication test, which sends the country
__authentication_probe__ and expects 400, and from a staging check that
polls an unknown job ID.
The gateway no longer records an exception before it returns:
- 400 from POST /simulate/economy/comparison and
POST /simulate/economy/budget-window when route resolution, or the
bundle and SPM lookup, raises a ValueError (four calls);
- 404 from GET /jobs/{job_id} when the gateway has no metadata for the
job ID;
- 404 from GET /budget-window-jobs/{batch_job_id} when no batch state or
seed exists for the batch ID;
- 404 from GET /versions/{kind} for an unknown kind.
The last three called routes_common.record_not_found, which has no
callers left and is removed. Status codes and response bodies are
unchanged, and no other log replaces the records.
Still recorded: the 500 when Modal's FunctionCall lookup fails during
GET /jobs/{job_id}, failed-job responses through
log_and_redact_exception, and the 404 for a job whose gateway metadata
exists but whose Modal call is missing or whose output has expired,
which is not clearly a client error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both submission handlers answer a ValueError or HuggingFaceDatasetReferenceError without an SPM error code, raised while describing the route's bundle or resolving SPM settings, with a 400 that repeats the exception text. No request input reaches that branch: coded SPM errors take the structured 400 response, and the bundle step reads the deployed bundle manifest, selected by the already-checked country and version. Only bad manifest data, such as a malformed dataset reference, can raise there, so it is a server failure. The bundle lookup tests for POST /simulate/economy/comparison and POST /simulate/economy/budget-window now expect: - status 500 with status "failed", a null result, no structured errors, and the error "Simulation failed (correlation_id=<32 hex digits>)"; - no part of the exception text in the response; - the observability ID header; - the exception recorded once as handled with status 500; - no Modal spawn. Both fail before the fix: the handlers return 400. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In submit_simulation and submit_budget_window_batch, an uncoded ValueError or HuggingFaceDatasetReferenceError raised while describing the route's bundle or resolving SPM settings used to become a 400 whose detail was the exception text. Only bad data in the deployed bundle manifest can raise there, so both handlers now treat it like the gateway's other server failures: - log_and_redact_exception logs the exception with its traceback, records it as handled with status 500, and returns "Simulation failed (correlation_id=<hex>)"; - failed_job_response returns that message as a 500 with status "failed", without structured errors, with the observability ID header. The caller no longer sees the exception text. The log scopes are simulation_submission and budget_window_submission, with the resolved app name as context. A coded SPM error still returns 400 with structured errors, unchanged. A ValueError from route resolution, such as an unknown country, still returns 400 without an exception record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /simulate/economy/comparison and POST /simulate/economy/budget-window can now answer 500 with a redacted error, so both routes declare it as "Server failure (redacted error with a correlation ID)", next to the existing 200, 400 and 422: - in the gateway's OpenAPI generator, with tests/golden/openapi.json regenerated (the only change is the two 500 entries); - in the entrypoint's route declarations, which publish the schema the simulation client is generated from. The entrypoint's contract test compares its OpenAPI document with the gateway's golden file, so the two descriptions are identical. The 500 has no response model, like the 500 on the status routes, because an unhandled server error still returns a plain-text body. The generated client is not committed; clients are published from the deployed commit. Regenerated locally, only the two submission modules change: a 500 is now a declared status that parses to None, where it used to raise UnexpectedStatus when raise_on_unexpected_status is set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main replaced changelog_entry.yaml with Towncrier fragments in changelog.d/ (rule sim.process.changelog-fragment), so the rebase drops this pull request's changelog_entry.yaml item and this commit adds changelog.d/962.changed.md, named after the tracking issue, #962. Its type, changed, puts it under Changed and raises the patch version. The fragment describes the same three changes as the old item: the gateway no longer records an exception for the 400 and 404 client errors it returns; an uncoded ValueError or HuggingFaceDatasetReferenceError from the bundle and SPM lookup now returns a recorded, redacted 500; and both economy submission routes declare that 500. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk
force-pushed
the
gateway-handled-client-errors
branch
from
October 29, 2026 14:57
8da9e21 to
9741269
Compare
anth-volk
marked this pull request as ready for review
October 29, 2026 17:36
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 #962
Fixes PolicyEngine/policyengine-api#4069
Same-Repository Draft PR Check
PolicyEngine/policyengine-sim-api, not a personal fork.Summary
The gateway no longer calls
runtime.record_exception(...)for requests it answers with a client error.policyengine-observabilitywrites each recorded exception at ERROR severity, and the request's ownrequest.completedrecord already reports these responses with outcomeclient_error. The visible symptom is PolicyEngine/policyengine-api#4069. Most of these records come from the deploy pipeline's authentication test, which sends the country__authentication_probe__and expects 400.Calls removed, with status codes and response bodies unchanged and no replacement log:
POST /simulate/economy/comparisonValueErrorfrom route resolution or the breakdown capability checkPOST /simulate/economy/comparison400500ValueErrorfrom the bundle and SPM lookup that has no SPM error code; now a recorded server error, see the update belowPOST /simulate/economy/budget-windowValueErrorfrom route resolutionPOST /simulate/economy/budget-window400500ValueErrorfrom the bundle and SPM lookup that has no SPM error code; now a recorded server error, see the update belowGET /jobs/{job_id}GET /budget-window-jobs/{batch_job_id}GET /versions/{kind}The last three went through
routes_common.record_not_found, which has no callers left and is removed.Still recorded:
FunctionCall.from_idfails duringGET /jobs/{job_id};log_and_redact_exception;GET /jobs/{job_id}when the gateway has metadata for the job but Modal reports the call missing or its output expired. The gateway submitted that job, so the 404 is not clearly a client error. One of the two calls is also in the failed-job branch that another change is editing.The entry service has no
record_exceptioncalls. Its 4xx responses (403 for a missing permission, 422 and 404 on the Stage 12 report routes) record no exception, and it relays the gateway's responses with an INFO event.docs/engineering/skills/observability.mdstates no rule about recording exceptions, so it is unchanged.Testing
Written test-first: the first commit adds the tests, and seven of them failed with the expected reason. The status code and body assertions passed, and the assertion that nothing was recorded failed.
tests/test_exception_records.py: an unknown country on both submission endpoints returns 400 and records nothing. (The bundle lookup tests originally expected the same; the update below changes them.) A Modal lookup failure onGET /jobs/{job_id}still records its exception as unhandled with status 500. That test passes before and after the fix.uv run --frozen pytest tests/in the gateway: 273 passed, 1 deselected (the opt-in Modal integration marker), before the rebase described below.test_canonical_spm.py,test_budget_window_scheduler.py): 67 passed.scripts/run_quality_guards.py,python -m scripts.rules guard --base-ref origin/main,python -m scripts.rules render(no diff), andpython -m scripts.rules pyright --sync projects/policyengine-simulation-gatewayall pass. The gateway's OpenAPI golden file was unchanged by the first two commits.Update, 2026-10-28: bundle-lookup errors return 500
The change
In both submission handlers, an error without an SPM error code raised while describing the route's bundle or resolving SPM settings (
ValueErrororHuggingFaceDatasetReferenceError) used to return 400 with the exception text asdetail. It now follows the gateway's convention for server failures:log_and_redact_exceptionlogs the exception with its traceback and records it as a handled error with status 500.failed_job_responsereturns 500 withstatus: "failed",result: null, no structurederrors, anderror: "Simulation failed (correlation_id=<hex>)". The observability ID header is set.simulation_submissionandbudget_window_submission.Unchanged:
errors.ValueError, such as an unknown country, still returns 400 without an exception record, as do the three 404s above.Why this is a server error
No request input can reach this branch. Every SPM error the request can cause has a code and takes the structured 400 response. The bundle step's only request inputs are the country, already checked during route resolution, and the version, which only selects a manifest. What can raise there is bad data in our own deployed bundle manifest, such as a malformed dataset address or reference. In the last 30 days the branch never fired: all 200 gateway 400 exception records came from
resolve_route.Schema and client changes
500: "Server failure (redacted error with a correlation ID)"next to 200, 400 and 422. The declaration is in the gateway's OpenAPI generator, withtests/golden/openapi.jsonregenerated (only the two 500 entries change), and in the entrypoint's route declarations. The entrypoint's contract test compares its document with the gateway's golden file. The 500 has no response model, like the 500 on the status routes, because an unhandled server error still returns a plain-text body.scripts/generate-clients.shbuilds it from the entrypoint's OpenAPI document, and client publication checks out the deployed commit. Regenerated locally, only the two submission modules change: a 500 is now a declared status that parses toNone, instead of raisingUnexpectedStatuswhenraise_on_unexpected_statusis set. Against the regenerated client, the localapis-integrun (-m "not requires_gcp and not beta_only", with the Docker Compose executor service) has 33 passed, 2 skipped and 6 deselected, and Pyright forprojects/policyengine-apis-integpasses.Downstream
No downstream change is needed:
Commits and tests (red, then green)
4b86bf50changes the bundle-lookup tests, for both handlers, to expect a 500 with the redacted body, no exception text, the observability header, one exception record (handled, status 500) and no Modal spawn. Both failed before the fix withassert 400 == 500.0715a7a5changes the two handlers. The tests pass.0965c748declares the 500 in the gateway's OpenAPI generator, its golden file and the entrypoint.97412692replaceschangelog_entry.yaml, whichmainno longer uses, with the changelog fragmentchangelog.d/962.changed.md.main; 273 and 742 before it.)scripts/run_quality_guards.py,python -m scripts.rules guard --base-ref origin/main,python -m scripts.rules render(no diff), and Pyright for the gateway, the entrypoint andapis-integall pass.Update, 2026-10-29: rebased onto
mainmainatf6d48051. The only conflict waschangelog_entry.yaml, whichmaindeleted when it moved to changelog fragments, so the branch no longer touches it.tests/golden/openapi.jsonandversion_routes.pymerged without conflict, and regenerating the OpenAPI document reproduces the merged golden file byte for byte.changelog.d/962.changed.md(typechanged) describes both changes: the exception records removed for client errors, and the 500 for bundle-lookup errors.scripts/run_quality_guards.py,python -m scripts.rules guard --base-ref origin/mainandpython -m scripts.rules render(no diff) pass. The higher counts include tests added onmain.🤖 Generated with Claude Code