Repository navigation
Answer 503 while the database is down, also behind HTTP Basic (B02) - #17
Closed
AsyncAssassin wants to merge 3 commits into
Closed
AsyncAssassin wants to merge 3 commits into
AsyncAssassin wants to merge 3 commits into
Conversation
The API promised 503 database-unavailable while PostgreSQL is down, but only a DataAccessException got it. A transaction that cannot get a connection fails with CannotCreateTransactionException, and a rollback on a connection the outage broke fails with TransactionSystemException. Both are TransactionExceptions, so seven of the nine endpoints answered 500 in local. In demo and prod, HTTP Basic could not read its user store, and the entry point answered 401 with a challenge, blaming valid credentials for the outage. Both transaction failures now get the same 503; other transaction exceptions, such as an unexpected rollback, stay 500. The entry point answers a user store failure caused by the database with that 503 and any other with the generic 500, both without a challenge, and logs database_operation_failed like the API. pgjdbc gets a 40-second socket timeout, above the 30-second statement_timeout, and a 5-second connect timeout, so a query to a server that stopped answering ends instead of holding the request thread and its connection forever. A test stops a dedicated PostgreSQL under running test and e2e contexts and checks every endpoint, the anonymous 401, health, and the socket timeout of pooled connections.
The immutable-conflict ProblemDetail example in docs/api.md and docs/architecture.md still said "amount or direction did not match" and lacked the address, asset, and conflictingFields the service writes. The error list in docs/architecture.md left out the 404 for an unsupported asset and the 409 for a duplicate account. docs/failure-modes.md promised metrics "when metrics are implemented" three times, although the ingest and transition counters exist, and a stale-confirmation log line with stored and incoming counts that the service never writes. It now names the meters and log lines as they are.
Review of the outage fix found that any database error behind the user store lookup became the outage answer: a Basic username with a NUL character, which PostgreSQL refuses as a data error, got 503 and an ERROR database_operation_failed line on a healthy database, where it used to get 401. Such a username is a bad credential again. One rule now decides what a database failure is, for the API, the credential check, and the sync worker: a Spring data access exception, a transaction that could not begin or roll back, or an SQL error Spring could not translate, such as a write to a read-only database, which answered 500. A sync run that meets one records "Database error" instead of "Unexpected error". Both entry paths write the same log line. The outage test checks that the credentials worked before the stop, a NUL username on the healthy database, the connect timeout, and that the socket timeout stays above statement_timeout; it sends the requests in parallel with the shortest pool wait, from 29 to 9 seconds. The docs describe the outage once, in docs/failure-modes.md, with the COMMIT that the socket timeout can cut off and its retry, and the examples show the fields in the order the service writes them.
Owner
Author
|
Combined into #23 together with the other batch-1 fixes, so they merge without a rebase per PR. This description keeps the detailed evidence for its fix. |
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.
Summary
B02 from the merged review of v0.4.0, with D05 and D10 from the same review; 0.4.1 is released once all fixes are merged.
docs/api.mdpromises503 database-unavailablewhile PostgreSQL is unavailable, but the service answered otherwise:localandtest: 7 of the 9 endpoints answered500 internal-error. A transaction that cannot get a connection fails withCannotCreateTransactionException, and a rollback on a connection the outage broke fails withTransactionSystemException; neither is aDataAccessException. An SQL error Spring cannot classify, such as a write to a read-only database, stayed a jOOQ exception and got500as well.demo,prod, and every other protected profile: a request with valid credentials got401with a Basic challenge, because HTTP Basic could not read its user store (InternalAuthenticationServiceException).statement_timeoutneeds a live server.What changes:
isDatabaseFailure(), decides what a database failure is for the API, the credential check, and the sync worker: a SpringDataAccessException, a transaction that could not begin or roll back, or an exception whose direct cause is anSQLException. Other transaction exceptions, such asUnexpectedRollbackException, stay500. A sync run that meets such a failure recordsDatabase error (<class>).instead ofUnexpected error (<class>)..503, and any other with the generic500, both without a challenge. A request without credentials keeps its401, and so does a username the store cannot hold, such as one with a NUL character. Both paths write the samedatabase_operation_failedline.socketTimeout=40, above the 30-secondstatement_timeout, andconnectTimeout=5. The 30-second Hikariconnection-timeoutstays: a shorter one would give false503s under load.docs/failure-modes.mdsection 6 is the one full description of the outage. It covers the timeouts, and aCOMMITthat the socket timeout cuts off but that may still commit, with what that means for a retry. It also notes that every request with credentials waits for the pool during the outage. The other files link to it.404for an unsupported asset and the409for a duplicate account.docs/failure-modes.mdnames the meters and log lines that exist instead of promising them "when metrics are implemented".CHANGELOG.mdlists the changes under[Unreleased]→ Fixed.Verification
./gradlew clean check: 280 tests, 0 failures, 2 skipped (the env-gated Alchemy live smoke).DatabaseOutageIntegrationTestsstarts its own PostgreSQL and boots atestand ane2econtext against it:401with the challenge;statement_timeoutits connections run with;503 database-unavailablewithout a challenge in both contexts, the anonymous request keeps its401, and health turns503.On the first version's handlers the same test reports 7 ×
500intestand 9 ×401ine2e. It now runs in 9 s instead of 29 s.Unit tests use the real exception classes:
CannotCreateTransactionException,TransactionSystemException, and a jOOQ exception around anSQLExceptiongive503;UnexpectedRollbackExceptionand jOOQ'sTooManyRowsExceptiongive500;503for a lookup that failed onCannotGetJdbcConnectionException,401with the challenge for one that failed on aDataIntegrityViolationException, and500otherwise.SyncApiIntegrationTests: aTransactionSystemExceptionduring a run leaves itQUEUEDwithDatabase error (TransactionSystemException)..Mutation check: dropping the NUL guard, the
SQLExceptionrule, or the worker rule, a socket timeout belowstatement_timeout, or a misspeltconnectTimeouteach turns a test red.Live
demoagainst its own PostgreSQL container, themainjar against this branch:main401with challenge401with challengedocker stop,demo-readerGET401with challenge503 database-unavailable, no challengedocker stop,demo-operatorPOST401with challenge503 database-unavailable, no challengedocker stop, no credentials401with challenge401with challengedocker startagain404(recovered)404(recovered)docker pausewith a query in flight503after 40 sThe
docker pauserow bypasses Hikari's connection check (-Dcom.zaxxer.hikari.aliveBypassWindowMs=3600000) to reproduce a connection taken right before the pause. A request that waits for a new connection gets its answer after the 30-second Hikariconnection-timeoutin both versions.A regression review of the first version (
/code-review) found that a NUL username on a healthy database got the outage answer. It also found untranslated jOOQ errors, the worker's run error, theCOMMITcaveat, and weaker tests and docs; all are fixed in0665722. Left as a documented limit: every request with credentials reads the user store, so during an outage even authenticated metrics requests wait for the pool. A credential cache or a separate management port would change that, and it is a decision of its own.This PR, #15, #16, #18, and #19 each add a section under
[Unreleased]inCHANGELOG.md, so each later merge needs a one-file rebase. The other files merge cleanly with all of them.