Skip to content

ADR-031: Concurrent OAuth Credential Refresh Across Lambda Invocations - #637

Merged
d-klotz merged 4 commits into
nextfrom
adr/concurrent-oauth-credential-refresh
Aug 20, 2026
Merged

ADR-031: Concurrent OAuth Credential Refresh Across Lambda Invocations#637
d-klotz merged 4 commits into
nextfrom
adr/concurrent-oauth-credential-refresh

Conversation

@d-klotz

@d-klotz d-klotz commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What this is

A Proposed ADR. It decides how Frigg handles concurrent OAuth credential refresh across separate Lambda invocations. It needs maintainer ratification, not merge-on-green.

Why now

A production Frigg app (Aspire → QBO) silently orphaned 95 records over 4 months. Verified root cause, with log and code evidence in the ADR's Context section:

  1. Two Lambda invocations refreshed the same QBO credential 368ms apart. The loser presented an already-rotated refresh token and got invalid_grant.
  2. The loser then flagged the shared credential invalid → integration ERROR → the queue worker silently acked every later message. No DLQ entry. That amplifier, not the race itself, is what turned 3 failed invoices into 95 lost records.

PR #636 (per-instance single-flight refresh) ships the in-process half. It cannot reach a race between invocations — this ADR is the cross-invocation half.

Prior art check

No existing ADR covers refresh concurrency. Nearest: ADR-005 (token refresh as an admin utility example), ADR-006 (credential endpoints), ADR-009 (flags refresh-on-401 as a low-coverage test gap). ADR-003 does not constrain this — it is scoped to the local-dev GUI.

The decision, in one paragraph

A layered stack, no new AWS infrastructure: (1) failure containment — a lost race is retryable, only genuinely dead credentials invalidate, and the worker's ERROR path stops silently acking (with a kill switch); (2) loser recovery — re-read the credential on 401/invalid_grant and adopt the winner's token, with an escalation rule when adoption fails; (3) compare-and-swap on the credential write via a plaintext tokenVersion; (4) core-owned token expiry plus a narrow pre-flight gate; (5) declared per-module rotation semantics; (6) optional jitter. Provable serialization (SQS FIFO per credential) is deferred behind a named metric (frigg.auth.refresh_race_lost).

The three designs you asked about

All three are evaluated in Alternatives Considered, on mechanism:

  • Central token service / proxy — rejected as the correctness mechanism (traced: at concurrency > 1 it closes 0% of the observed window; its own outage reproduces the silent-loss chain fleet-wide). Kept alive as a possible later observability topology.
  • DB-event / SNS subscription — rejected. No SNS protocol can deliver into a running invocation; the implementable form degenerates to a re-read with extra infrastructure and a connection-leak trap (Frigg has no teardown path).
  • Per-API flag + re-read on 401 + dual-write cache — the re-read is adopted as layer 2 and made unconditional (the flag would leave 35 rotation-exposed modules broken until 35 authors opt in). The dual-write cache is rejected: no write ordering is both useful and safe, and the datastore already is the shared cache.

What ratification requires

Two named calls, in the open-question section:

  1. Is "no failure is silent, losers always recover" a sufficient contract, or does Frigg need provable serialization (layer 7)?
  2. Layer 1 is default-on, which is an argued exception to ADR-027's no-behavior-change-on-upgrade bar.

Review notes

  • Numbered 031: 028–030 are claimed by ADR drafts on unmerged branches (claude/frigg-adr-ssm-env-management, claude/frigg-adrs-app-init-skills-pipeline).
  • The document went through two adversarial review passes; every file:line citation was verified against source, and the register files (README table, website/roadmap/data/adrs.json) are updated.
  • Unverified items are labelled in-text (Intuit's exact rotation interval/grace window, per-provider grant-revocation behavior, AWS latency estimates).

🤖 Generated with Claude Code

A production Frigg app silently orphaned 95 records over 4 months. The
trigger was a cross-invocation credential rotation race: separate Lambda
invocations each hydrate the credential into memory, refresh without
re-reading it, and persist with a blind last-writer-wins upsert. The
loser presents an already-rotated refresh token, gets invalid_grant,
flags the shared credential invalid, and the queue worker then silently
acks every subsequent message.

PR #636 shipped a per-instance single-flight refresh; it cannot reach a
race between invocations. No existing ADR covers refresh concurrency
(nearest: ADR-005 names token refresh as an admin utility, ADR-006
defines credential endpoints, ADR-009 flags refresh-on-401 as a test
gap).

The proposed decision is a layered stack: failure containment in the
invalidation and worker paths, loser recovery by re-read-and-adopt,
compare-and-swap on the credential write, core-owned token expiry with
a narrow pre-flight gate, declared per-module rotation semantics, and
optional jitter. Provable serialization (SQS FIFO per credential) is
deferred behind a named metric. The maintainer's three candidate
designs (central token service, DB-event subscription, per-API re-read
flag with a dual-write cache) are evaluated in Alternatives Considered;
the re-read half of the third is adopted, the rest rejected on
mechanism.

Status is Proposed. One open question is named for ratification, plus
the default-on exception to ADR-027's opt-in bar. Numbered 031 because
028-030 are claimed by drafts on unmerged branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

Claude Code Review is paused for this repository. To reconnect it, an admin of this repository's GitHub organization (or the account owner, for personal repositories) who can also manage your Claude organization's Code Review settings needs to re-link GitHub in Code Review settings. This is a one-time step.

Tip: disable this comment in your organization's Code Review settings.

@netlify

netlify Bot commented Aug 11, 2026

Copy link
Copy Markdown

Deploy Preview for friggframework-org canceled.

Name Link
🔨 Latest commit ff0acc1
🔍 Latest deploy log https://app.netlify.com/projects/friggframework-org/deploys/6a7f651d18bfa000088bdfbe

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f24409b596

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +318 to +322
adopted token is itself rejected on the retry. Either one means nobody holds
a valid token — this is no longer a lost race, it is a genuinely dead
credential (user revoked access, or a whole-grant-revoking provider killed
the winner's tokens too; see "Provider semantics"). At that point the
original behavior is correct and must run: `markCredentialsInvalid()`, a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep transient refresh failures retryable

When the token endpoint fails with a timeout, network error, 429, or 5xx, the re-read will legitimately find no newer token, but that does not mean the credential is dead. The current OAuth2Requester.refreshAuth() funnels every thrown error through the same failure path (oauth-2.js:317-330), so this escalation rule would mark a healthy credential invalid and put its integrations into ERROR during a provider outage. Restrict invalidation to definitive authorization rejections such as invalid_grant, while leaving transport and provider failures retryable.

Useful? React with 👍 / 👎.

Comment on lines +311 to +313
`invalid_grant` after failing, re-read the credential; if the persisted
`access_token` differs from the in-memory copy, adopt it, bump
`_authGeneration`, and retry via `requester.js:379`. The datastore is the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detect winner adoption by refresh generation

For a rotating provider that returns the same access-token value while issuing a new refresh token, this test misses the winner even though layer 1 correctly detects the changed refresh_token; the loser then follows the adoption-failure path and invalidates a usable credential. OAuth does not make access-token string inequality a refresh-generation invariant, so adoption should be keyed to the changed refresh token or the proposed tokenVersion, and should copy the complete persisted token state.

Useful? React with 👍 / 👎.

Same decision, same facts, same code citations, same numbers. Only the
language changes: short sentences, active voice, one idea per sentence,
and a terms list at the top. A checklist pass verified that every code
reference, measurement, and count from the previous revision is present
in the new text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@seanspeaks seanspeaks left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ADR is too verbose by 60%+ (rough estimate)

The problem can be stated pretty succinctly... Frigg's API modules and requester approach currently does not have any way to deconflict when parallel processes run into auth refresh errors for any OAuth or session based auth. That leads to multiple parallel processes refreshing credentials in-memory and then whichever was the "last write" wins in the DB.

In most cases, this is a non-issue when the API provider allows for either multiple live access tokens, or multi use for a given refresh token.

In some cases, it's an acute issue. Either generating an access token invalidates all prior ones; or refresh tokens are single use.

^^ we can get into more details by pointing to the parts of the code where this happens or doesn't happen, ultimately in the Requester class and the api class.

From this we have some options.

  1. We can have each invocation/process/thread "listen" for changes to the specific database table/models upon connection. Any changes will then propagate to the in-process instance of the api class, swapping out or referencing the newly refreshed credential details
  2. We can proactively refresh using the new admin scripts with optional core scripts that can run on a cron
  3. We can pre-flight freshly fetch a credential, either naively (every time), or in a specific context (i.e. when it's known this is a parallel request, a flag gets added/passed into the api class instance)
  4. We can reactively check against the DB first before we attempt an auth refresh or retry (on 401).

Option 1 feels like it adds too much overhead and may be brittle. Alternatively, it's a good pattern broadly to consider and maybe use in Frigg with distributed workloads.

Option 2 seems like we should build it anyway and have it be automatically installed or offered to be installed via CLI whenever someone installs an API module that has expiritng OAuth tokens with refresh. It won't solve everything but it likely would make the problem more rare for a low cost.

Option 3 feels like we'd incur too much overhead for each request, essentially flooding requests to the DB.

Option 4 feels like the right answer, with the caveat noted below.

1, 3 and 4 also would need to consider delays to the DB read/writes... i.e. maybe it takes an extra 2-5 seconds for a db write to percolate? Depending on how heavily used the DB is?

Verdict/recommendation is Option 2 and 4

From there we can dig into the architecture and considerations.

Responds to the review on PR #637 (changes requested):

- Cut from 1027 to 337 lines (~67%).
- Restructured around the review's four options. The decision is
  options 4 and 2 together: a reactive database check before refresh
  and on 401 as the mechanism, and proactive scheduled refresh via the
  admin scripts as the companion.
- Folded in both inline review findings: the adoption test keys on the
  refresh token (not the access token), and only definitive
  authorization rejections may invalidate a credential — transport
  failures (timeout, 429, 5xx) stay retryable.
- Addressed the database-propagation caveat with the measured 716ms
  window, bounded re-read backoff, and the readPreference=primary
  requirement.
- Compare-and-swap moved from load-bearing to deferred hardening. The
  serialization family stays deferred behind the named metric.
- The full prior analysis remains available at commit 42d1e6f.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

Claude Code Review is paused for this repository. To reconnect it, an admin of this repository's GitHub organization (or the account owner, for personal repositories) who can also manage your Claude organization's Code Review settings needs to re-link GitHub in Code Review settings. This is a one-time step.

Tip: disable this comment in your organization's Code Review settings.

The scheduled-refresh companion no longer requires core changes. The
script hydrates each module and calls refreshAuth() serially; the
existing setTokens -> DLGT_TOKEN_UPDATE -> onTokenUpdate ->
upsertCredential chain persists the result with no new code. Expiry
tracking (the persist/hydrate work and the null-expires_in computation
fix) moves from prerequisite to optional later optimization, taken only
if the bounded waste of blind refreshes ever matters.

Option 4 is unchanged: the database lookup before any refresh, and the
re-read-and-adopt on 401/invalid_grant, remain the mechanism and ship
first. The sequence shrinks from three PRs to two.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

d-klotz added a commit that referenced this pull request Aug 14, 2026
…ng it

Implements ADR-031 option 4 (frigg PR #637), the cross-invocation half
of the refresh race. Separate Lambda invocations hydrate the same
credential, refresh from stale memory, and the loser gets invalid_grant
— then flags the shared credential invalid, which silently killed 95
records in a production app over four months.

refreshAuth() now asks its delegate for the stored credential (new
DLGT_CREDENTIAL_RELOAD, handled read-only by Module.reloadCredential)
and adopts it when the stored refresh_token differs from the in-memory
one: another invocation already refreshed, so adopting always beats
spending a rotation. The comparison is keyed on the refresh token,
never the access token — a provider can rotate one and return the
other unchanged (review finding on the ADR).

Escalation is now restricted to proof of death: a definitive
authorization rejection (invalid_grant / invalid_client) AND no newer
token appearing across a bounded re-read backoff (default 500/1000/
1500ms; the observed winner-write-to-loser-failure gap in production
was 716ms). Transport failures — timeouts, 429s, 5xx, unmarked 400s —
now rethrow as retryable instead of invalidating a healthy credential
(second review finding). Two counters expose the outcome:
frigg.auth.refresh_race_recovered and frigg.auth.refresh_race_lost.

Four existing oauth-2 tests encoded the old any-error-invalidates
contract and were updated to the new one; their original intents (no
secret leak to delegates, no retry after a failed refresh) are
preserved and still asserted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@d-klotz
d-klotz merged commit 77d9d29 into next Aug 20, 2026
12 of 13 checks passed
@d-klotz
d-klotz deleted the adr/concurrent-oauth-credential-refresh branch August 20, 2026 13:47
@seanspeaks

Copy link
Copy Markdown
Contributor

🚀 PR was released in v2.0.0-next.108 🚀

@seanspeaks seanspeaks added the prerelease This change is available in a prerelease. label Aug 21, 2026
@seanspeaks

Copy link
Copy Markdown
Contributor

🚀 PR was released in v2.0.0-next.108 🚀

2 similar comments
@seanspeaks

Copy link
Copy Markdown
Contributor

🚀 PR was released in v2.0.0-next.108 🚀

@seanspeaks

Copy link
Copy Markdown
Contributor

🚀 PR was released in v2.0.0-next.108 🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prerelease This change is available in a prerelease.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants