fix: backend statement timeouts answer 504, not 500 (#353) - #439
Open
mauripunzueta wants to merge 2 commits into
Open
fix: backend statement timeouts answer 504, not 500 (#353)#439mauripunzueta wants to merge 2 commits into
mauripunzueta wants to merge 2 commits into
Conversation
A statement cancelled by a server-side deadline — PostgreSQL `statement_timeout` (SQLSTATE 57014), MongoDB `maxTimeMS`, an explicit SQLite interrupt — surfaced as HTTP 500 `exception`, telling clients a transient resource condition was a server defect and hiding it from 5xx-by-class dashboards. Refs #353. The issue proposed classifying inside `From<tokio_postgres::Error>`. That alone would not have fixed the reported symptom: the hot query paths never use `From`. Each backend module has `fn internal_error(message: String)` and calls `.map_err(|e| internal_error(format!("...: {e}")))`, which stringifies the error and discards `err.code()` — including the #279 search path (`search_impl.rs`, "Failed to execute search") the issue cites. Matching on the message text instead is not an option: PostgreSQL localizes error messages via `lc_messages`, so SQLSTATE is the only stable signal, and it must be read while the typed error is still in hand. Classification (all backends): - Add `BackendError::Timeout`, distinct from `Unavailable`: the backend is healthy and deliberately stopped one over-long statement. - Add `classify_{postgres,sqlite,mongodb}_error(context, err)`, preserving SQLSTATE / `ErrorCode` / server error code, and route the three `From<DriverError>` impls through them. - PostgreSQL 57014 -> Timeout; 53300/53400/57P01/57P02/57P03 -> Unavailable. 40001/40P01 deliberately left alone — retryable, but a separate decision. - SQLite SQLITE_INTERRUPT -> Timeout; SQLITE_BUSY/LOCKED after `busy_timeout` -> Unavailable (503), not Timeout: lock contention is worth retrying. Both were 500 before. - MongoDB MaxTimeMSExpired(50)/ExceededTimeLimit(262) -> Timeout; Io/ConnectionPoolCleared/ServerSelection -> Unavailable. - S3 already classified its throttling correctly and is unchanged. Call sites (145 across postgres/sqlite/mongodb): a typed `query_error(context, err)` per backend replaces `internal_error(format!(...))` on the statements whose cost scales with tenant data volume — search, count, aggregate, history scan, purge, tenant-wide index clear. Those are the statements a deadline actually fires on. Two properties make this safe: the unclassified fallback is byte-identical to the message the call site produced before, and the helper takes the driver error *by type*, so a site whose closure yields some other error (serde, chrono, a parse) fails to compile rather than being silently mis-converted. REST: - Add `RestError::GatewayTimeout` -> 504 + FHIR `timeout` issue code. - 504 rather than 503 because a 503 reads to load balancers and service meshes as "eject this instance"; a statement timeout says nothing about instance health, and ejecting under that load would be actively harmful. - No `Retry-After`: a cancelled query is usually deterministically too slow, so inviting a prompt retry only adds load. The 503 family keeps its hint. - The client message is sanitized and backend-agnostic. The issue asked it to name `HFS_PG_STATEMENT_TIMEOUT_MS`; that is declined deliberately — it fingerprints the deployment while being useless to a caller who cannot set a server env var. The knob, SQLSTATE and driver text go to the log instead. - `TransactionError::Timeout` also moves 500 -> 504, in both `From<TransactionError>` and the parallel bundle mapping in `batch.rs`, so a transaction timeout reports the same status on either path. Tests: SQLite classifier unit tests (interrupt/busy/locked/passthrough, incl. the byte-identical fallback); a PostgreSQL testcontainer regression test asserting SQLSTATE 57014 reaches the classifier as `Timeout` (`tokio_postgres::Error` has no public constructor, so it cannot be a unit test); REST 504/`timeout` mapping, message-sanitization, absence of `Retry-After`, and a new row in the exhaustive BackendError->status table. Scope note: bulk_export/bulk_submit (errors surface in job manifests, not HTTP status) and schema DDL (a cancelled migration should fail loudly) still report 500 and are unconverted.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
…osure The #353 fix rewrote ~150 backend call sites from .map_err(|e| internal_error(format!("<ctx>: {e}")))? to a per-backend `query_error(<ctx>, e)` helper so the driver's SQLSTATE / ErrorCode survives long enough to be classified. That kept the closure spelling, which has two costs: the driver error type is re-bound at every site, and the conversion lives in a closure body that only executes on failure — so coverage tooling reports every error-handling site in the backends as unexecuted. Rewriting all of them at once made that artifact impossible to miss (56% patch coverage on a change whose own logic is unit tested). Replace the three `query_error` free functions with one `QueryErrorExt` trait in `persistence::error`, implemented for `Result<T, rusqlite::Error>`, `Result<T, tokio_postgres::Error>` and `Result<T, mongodb::error::Error>`: .or_query_error("Failed to prepare count_by_types")? Classification is unchanged and still lives in `classify_sqlite_error` / `classify_postgres_error` / `classify_mongodb_error`. Because each impl is written for one concrete driver error type, a site whose Result carries some other error still fails to compile rather than being mis-converted. Also cover the classification paths that had no test: - `classify_mongodb_error` — deadline codes 50/262 → Timeout, an I/O failure → Unavailable, any other command error → Internal with byte-identical text, plus the `From` impl used by bare `?`. `CommandError` is non_exhaustive with a private field but derives Deserialize, so the tests build one through serde exactly as the driver does. - `From<tokio_postgres::Error> for StorageError` — the integration test now also converts a cancelled statement with `?`, not just via the classifier. - `RestError::GatewayTimeout`'s Display, which is what reaches operator logs.
mauripunzueta
marked this pull request as ready for review
July 29, 2026 19:17
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.
Closes #353.
What was actually wrong
A statement cancelled by a server-side deadline surfaced as HTTP 500
exception— telling clients a transient resource condition was a server defect, and burying it in the same 5xx bucket as genuine panics.Two corrections to the issue as filed:
1. The "leaked driver message" premise is already stale.
f92f04558(PR #333, 2026-07-21 — two days before this issue was filed) madeRestError::InternalErrorsanitize: it logs the detail server-side and returns a fixed generic string. No raw driver text reaches the OperationOutcome today. What remained was the wrong status, plus the loss of any actionable signal.2. The proposed fix would not have fixed the reported symptom. The issue asked for classification inside
From<tokio_postgres::Error>. The hot query paths never useFrom. Each backend module hasfn internal_error(message: String)and calls:which stringifies the error and throws away
err.code(). That includessearch_impl.rs— the exact #279 path this issue cites. There are 203 such sites in the Postgres backend alone (269 SQLite, 146 MongoDB).Matching on the message text instead is not viable: PostgreSQL localizes error messages via
lc_messages, so"canceling statement due to statement timeout"is not a stable signal on a non-English server. SQLSTATE is, and it must be read while the typed error is still in hand.Approach
Classification — all backends. New
BackendError::Timeout, distinct fromUnavailable: the backend is healthy and deliberately stopped one over-long statement. Newclassify_{postgres,sqlite,mongodb}_error(context, err)preserve the driver code; the threeFrom<DriverError>impls now route through them.57014 query_canceled53300/53400/57P01/57P02/57P03SQLITE_INTERRUPTSQLITE_BUSY/SQLITE_LOCKED(afterbusy_timeout)MaxTimeMSExpired(50) /ExceededTimeLimit(262)Io/ConnectionPoolCleared/ServerSelectionSQLite lock-wait expiry is deliberately 503, not 504: it is contention, not an over-long query, and a retry genuinely succeeds.
40001/40P01are deliberately left alone — retryable, but what a FHIR client should see for a write conflict is a separate decision, and silently 503-ing a deadlock could mask a real lock-ordering defect.Call sites — 145 across three backends. A typed
query_error(context, err)per backend replacesinternal_error(format!(...))on the statements whose cost scales with tenant data volume: search, count, aggregate, history scan, purge, tenant-wide index clear. Those are the statements a deadline actually fires on — a primary-key read cannot run 30s, and if it could, pool exhaustion (already 503) fires first.Two properties make a mechanical diff this size safe to review:
REST. New
RestError::GatewayTimeout→ 504 + FHIRtimeoutissue code.Retry-After. Per Postgres composite search is I/O-bound at volume — needs a denormalized one-row-per-group storage layout #279 the cancelled shapes run 12s median / 500s worst — deterministically too slow for the budget. Advising a prompt retry just multiplies load. The 503 family keeps its hint, where a retry helps.HFS_*variable. The issue asked the diagnostic to nameHFS_PG_STATEMENT_TIMEOUT_MS; declined deliberately. That fingerprints the deployment while being useless to a caller who cannot set a server env var — and it would regress the sanitization fix: a down backend answers 503+Retry-After, and /_readiness actually probes storage (#286) #333 just established. The knob, the SQLSTATE and the driver text go to the log, for the party who can act on them.TransactionError::Timeoutalso moves 500 → 504 — the same mis-classification, two lines away — in bothFrom<TransactionError>and the parallel bundle mapping inbatch.rs, so a transaction timeout reports the same status on either path.Tests
Timeout, busy/locked →Unavailable, passthrough →Internal, and an explicit assertion that the fallback message is byte-identical to the pre-change text.statement_timeout=250ms+SELECT pg_sleep(5), asserting SQLSTATE57014reaches the classifier asTimeoutwith the caller's context preserved. It asserts on the SQLSTATE, not the message text, so it does not become locale-dependent. This has to be an integration test —tokio_postgres::Errorhas no public constructor.timeoutmapping, message sanitization (asserts the diagnostic leaks no driver text, product name, or env var), absence ofRetry-After, transaction-timeout mapping, and a new row in the exhaustiveBackendError→ status spec table from A down backend answers 500, not 503 — and /_readiness hardcodes "storage": "ok" #286.Deliberately out of scope
The status map is not uniform after this PR, and that is a choice rather than an oversight:
bulk_export.rs/bulk_submit.rs(79 pg / 116 sqlite / 18 mongo sites) — these errors surface in job status manifests, not HTTP status codes, so converting them changes nothing user-visible.schema.rs— startup DDL. A cancelled migration should fail loudly rather than become a client-facing 504.40001/40P01— see above.maxTimeMSis not currently set on HFS queries, so the Mongo timeout arm only fires when the deadline comes from the server or connection string. The classification is in place either way; wiring an HFS-side query deadline is a separate change.Verification note
This environment has no C linker, so
cargo check/test/clippycannot run locally —cargo fmt --checkpasses (which confirms every file parses). CI is the first real compile. I hand-verified the driver APIs against the vendored sources, which caught one bug the compiler would have:SqlStateis a newtype over an enum with anOther(Box<str>)variant, making it non-structural-match, soSqlState::QUERY_CANCELEDcannot appear in a pattern — the classifier compares with==instead.