Skip to content

fix(auth): never crash the api on an orphaned kratos identity; self-heal missing accounts - #499

Merged
islandbitcoin merged 4 commits into
mainfrom
fix/session-orphan-crash
Sep 2, 2026
Merged

fix(auth): never crash the api on an orphaned kratos identity; self-heal missing accounts#499
islandbitcoin merged 4 commits into
mainfrom
fix/session-orphan-crash

Conversation

@islandbitcoin

Copy link
Copy Markdown
Contributor

Root cause

On 2026-09-01 all three prod api replicas crash-looped (restart count 10, CrashLoopBackOff 20:37–20:47Z) because of one logged-in user.

  1. The Kratos after-registration web_hook runs with response.parse: false: the identity is committed whether or not POST /kratos/registration managed to write the account. During today's signup wave the registration write failed 26× with DuplicateKeyForPersistError and 13× with IbexError. 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).
  2. sessionPublicContext threw mapError(CouldNotFindAccountFromKratosIdError) on every request carrying that session.
  3. setGqlContext is 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=throw is 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:

UnexpectedClientError: Unexpected error occurred, please try again or contact support if it persists (code: CouldNotFindAccountFromKratosIdError: ebbe2b32-…)
    at mapError (/app/lib/graphql/error-map.js:784:20)
    at sessionPublicContext (/app/lib/servers/middlewares/session.js:33:44)
    at async setGqlContext (/app/lib/servers/graphql-main-server.js:27:24)
Node.js v24.14.0

Changes

  1. setGqlContextsrc/servers/middlewares/gql-context.ts, wrapped. Never rejects. An AuthenticationError from session resolution is answered as HTTP 200 + GraphQL error NOT_AUTHENTICATED (same reasoning as apiKeyRateLimitMiddleware: the federation router swallows non-2xx subgraph responses into an opaque SUBREQUEST_HTTP_ERROR); anything else is a 500. Logged at error with kratosUserId and recorded on the span.
  2. sessionPublicContext self-heals orphans. On CouldNotFindAccountFromKratosIdError it loads the Kratos identity and re-runs createAccountWithPhoneIdentifier from 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).
  3. mapError: CouldNotFindAccountFromKratosIdErrorAuthenticationError instead of the "unexpected error, please try again" catch-all, which invited the client to retry the exact request that crashed the api.
  4. unhandledRejection handler in the api entrypoint: logs + records, no exit. No uncaughtException handling added.
  5. parseRepositoryError keeps the driver message on DuplicateKeyForPersistError so a failed registration write can be attributed to the colliding index (accounts.kratosUserId vs users.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 in graphql/error-map.spec.ts.

Test Suites: 226 passed, 226 total
Tests:       3 skipped, 2431 passed, 2434 total
tsc-check: exit 0, 0 errors
eslint (changed files): clean

Known limitation

The repair re-runs the pre-existing registration sequence (users.updateaccounts.persistNew → wallet init). If wallet init fails after persistNew, 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)

  • Close the orphan-creation side: set can_interrupt: true on the Kratos after-registration web_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.ts still calls sessionPublicContext unguarded inside the graphql-ws context callback.
  • Reconcile the 17 pre-existing orphans (or let this PR's self-heal do it on their next login).

🤖 Generated with Claude Code

https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8

…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
bobodread876 and others added 3 commits September 1, 2026 16:13
…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
@islandbitcoin

Copy link
Copy Markdown
Contributor Author

Simon review: 3 rounds. Round 1 blocker (global CouldNotFindAccountFromKratosIdError → AuthenticationError remap leaking into admin lookups) and its 3 should-fixes were applied in b4dd5e6; round 2's finding (repair dropped phoneMetadata) in 1071a38; round 3's last finding (billed carrier lookup + flat 60s retry on unrepairable orphans) in the commit above: exponential backoff capped at 1h and a per-identity lookup cache. Changed specs: 5 suites / 35 tests green; tsc only the 2 pre-existing Express errors; eslint clean.

🤖 Generated with Claude Code

@islandbitcoin
islandbitcoin merged commit 4f5329d into main Sep 2, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants