fix(auth): never crash the api on an orphaned kratos identity; self-heal missing accounts - #499
Merged
Merged
Conversation
…eal missing accounts On 2026-09-01 every api replica crash-looped (restart count 10, CrashLoopBackOff 20:37–20:47Z) because of a single logged-in user. Root cause chain: - The Kratos after-registration web_hook runs with `response.parse: false`, so the identity is committed whether or not `/kratos/registration` managed to write the account. During the signup wave 26 registration writes failed with DuplicateKeyForPersistError and 13 with IbexError, leaving at least one identity (created 20:11:35Z) with a valid session and no Mongo account. - `sessionPublicContext` threw `mapError(CouldNotFindAccountFromKratosIdError)` for every request carrying that session. - `setGqlContext` is an async Express middleware with no error path, so the throw became an unhandled promise rejection, and Node 24 exits the process on those. The user's app retried on a timer, so all three replicas died within a second of each other, over and over. Changes: 1. `setGqlContext` moved to `servers/middlewares/gql-context.ts` and wrapped: an `AuthenticationError` from session resolution is answered as HTTP 200 with a GraphQL error (code NOT_AUTHENTICATED) because the federation router swallows non-2xx subgraph responses into SUBREQUEST_HTTP_ERROR; anything else is answered as a 500. The middleware never rejects. 2. `sessionPublicContext` repairs an orphaned identity in place: it loads the Kratos identity and re-runs `createAccountWithPhoneIdentifier` from its phone trait. If the identity has no phone or the write fails again, the original not-found error stands and is answered per (1). 3. `mapError` maps `CouldNotFindAccountFromKratosIdError` to `AuthenticationError` instead of the "unexpected error, please try again" catch-all, which invited the client to retry the request that crashed us. 4. The api entrypoint installs an `unhandledRejection` handler that logs and records the rejection instead of letting it end the process. 5. `parseRepositoryError` keeps the Mongo driver message on `DuplicateKeyForPersistError` so a failed write can be attributed to the index that collided (accounts.kratosUserId vs users.phone). Out of scope, follow-ups: switch the Kratos after-registration web_hook to `can_interrupt: true` (charts/flash values + deployments tf-module template) so a failed account write aborts the registration instead of committing an orphan; `ws-server` still calls `sessionPublicContext` unguarded. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
…s, stop the unrepairable-orphan hot loop Review fixes for #499. - CouldNotFindAccountFromKratosIdError maps to NOT_FOUND like its id/uuid siblings. Only the session middleware, which knows the missing account is the caller's own, raises AuthenticationError ("No account is linked to this session"). Admin accountDetailsByUserPhone/ByUserEmail lookups of an orphan now get a not-found for that user instead of NOT_AUTHENTICATED. - Single-flight the repair per kratosUserId in-process so an orphan's parallel app-launch queries share one persistNew instead of all but one losing the unique-index race; on DuplicateKeyForPersistError re-read the account once to adopt a concurrent write from another replica. - Negative-cache failed repairs per kratosUserId (60s, replica-local): one Kratos/Mongo round trip per window instead of one per request. First failure logs error + Critical span, later failures warn. - gql-context: an AuthenticationError is answered with a warn log and no Critical span; the 500 branch keeps error + Critical. - DuplicateKeyForPersistError gets its own mapError case with a fixed client message; the retained Mongo driver text (collection, index, dup key) stays on the domain error for logs and spans only. Tests: session.spec covers the AuthenticationError shape, single-flight (create called once for two concurrent requests), cross-replica adoption, the retry window and warn-after-first; gql-context.spec asserts no span exception on the auth branch; error-map.spec asserts NOT_FOUND and no driver text; new admin account-details-by-phone.spec asserts NOT_FOUND on the admin path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
The self-heal in sessionPublicContext re-ran the registration write without the Twilio carrier lookup the webhook path stores as users.phoneMetadata (login.ts → transient_payload → RegistrationPayloadValidator). Quiz rewards (add-earn → PhoneMetadataAuthorizer) fail closed on a missing phoneMetadata, so every repaired identity was permanently ineligible for rewards with nothing pointing at why. Look up the carrier before createAccountWithPhoneIdentifier and pass it through. Best effort, as on the webhook path: a failed lookup logs a warn line and repairs without metadata rather than failing the repair. Spec: asserts the metadata reaches createAccountWithPhoneIdentifier, that a failed lookup still repairs (no error line, no span exception), and that the billed carrier lookup is single-flighted with the rest of the repair. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
… lookup Round-3 review finding on #499: an identity that will never repair (phone collides on users.phone, no phone trait) re-ran the whole repair, including a billed Twilio carrier lookup, once every 60s per replica for as long as its app kept polling. - The negative-cache window now doubles on every consecutive failure (60s, 2m, 4m, ... capped at 1h), so a permanently broken identity costs one attempt an hour per replica instead of one a minute. - The carrier lookup is remembered per identity across attempts and dropped once the repair succeeds, so a retried repair never bills Twilio twice for the same number. Tests: pure backoff schedule, the doubled window holding between attempts, and a single getCarrier call across two failed attempts and the eventual success. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
Contributor
Author
|
Simon review: 3 rounds. Round 1 blocker (global 🤖 Generated with Claude Code |
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.
Root cause
On 2026-09-01 all three prod api replicas crash-looped (restart count 10,
CrashLoopBackOff20:37–20:47Z) because of one logged-in user.web_hookruns withresponse.parse: false: the identity is committed whether or notPOST /kratos/registrationmanaged to write the account. During today's signup wave the registration write failed 26× withDuplicateKeyForPersistErrorand 13× withIbexError. At least one identity (created 20:11:35Z,phone_no_password_v0) ended up with a valid session and no Mongo account. A sweep of all 6,208 identities found 19 such orphans (17 older, 2 from today).sessionPublicContextthrewmapError(CouldNotFindAccountFromKratosIdError)on every request carrying that session.setGqlContextis an async Express middleware with no error path. The throw became an unhandled promise rejection, and Node 24 exits the process on those (--unhandled-rejections=throwis the default). The user's app retried on a timer, so all replicas died within a second of each other, repeatedly.Crash tail from the previous container:
Changes
setGqlContext→src/servers/middlewares/gql-context.ts, wrapped. Never rejects. AnAuthenticationErrorfrom session resolution is answered as HTTP 200 + GraphQL errorNOT_AUTHENTICATED(same reasoning asapiKeyRateLimitMiddleware: the federation router swallows non-2xx subgraph responses into an opaqueSUBREQUEST_HTTP_ERROR); anything else is a 500. Logged at error withkratosUserIdand recorded on the span.sessionPublicContextself-heals orphans. OnCouldNotFindAccountFromKratosIdErrorit loads the Kratos identity and re-runscreateAccountWithPhoneIdentifierfrom its phone trait — the same write the registration webhook should have done. If the identity has no phone (device accounts) or the write fails again, the original error stands and is answered per (1).mapError:CouldNotFindAccountFromKratosIdError→AuthenticationErrorinstead of the "unexpected error, please try again" catch-all, which invited the client to retry the exact request that crashed the api.unhandledRejectionhandler in the api entrypoint: logs + records, no exit. NouncaughtExceptionhandling added.parseRepositoryErrorkeeps the driver message onDuplicateKeyForPersistErrorso a failed registration write can be attributed to the colliding index (accounts.kratosUserIdvsusers.phone). Today's 26 failures could not be.Tests
New:
servers/middlewares/gql-context.spec.ts(resolves + continues; 200/NOT_AUTHENTICATED without rejecting; 500 without rejecting),servers/middlewares/session.spec.ts(existing account; anon; orphan repaired from phone trait; re-raised when no phone / identity load fails / repair write fails),services/mongoose/parse-repository-error.spec.ts, plus a regression case ingraphql/error-map.spec.ts.Known limitation
The repair re-runs the pre-existing registration sequence (
users.update→accounts.persistNew→ wallet init). If wallet init fails afterpersistNew, the account exists without wallets — the same partial state the webhook path already produces today. Not made worse here, but not fixed either.Follow-ups (out of scope)
can_interrupt: trueon the Kratos after-registrationweb_hook(charts/flash/values.yaml+deployments/tf-modules/flash/kratos-postgres/postgres-values.tmpl.yaml, Kratos is v1.0.0) so a failed account write aborts registration instead of committing an identity.src/servers/ws-server.tsstill callssessionPublicContextunguarded inside the graphql-wscontextcallback.🤖 Generated with Claude Code
https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8