Skip to content

fix(fcm): retry transient OAuth2 failures behind a single-flight refresh - #41

Merged
AndreaDiazCorreia merged 3 commits into
mainfrom
fix/fcm-oauth-retry
Sep 1, 2026
Merged

fix(fcm): retry transient OAuth2 failures behind a single-flight refresh#41
AndreaDiazCorreia merged 3 commits into
mainfrom
fix/fcm-oauth-retry

Conversation

@AndreaDiazCorreia

Copy link
Copy Markdown
Member

A failed OAuth2 token exchange returned Err immediately and the dispatch was discarded, so a transient 503 from Google silently dropped a notification. FCM is the only backend enabled in production, which makes this the live failure mode of the push path.

Retrying alone would have made the outage worse

Two things surfaced while scoping this, neither in the original issue.

The retry runs on a borrowed semaphore permit. The sequence executes while the caller holds one of the 50 /api/notify permits, and notify_token drops the dispatch silently when the pool saturates (src/api/notify.rs:106-110) — it still returns 202, because the privacy contract forbids distinguishing outcomes. A failed exchange currently costs ~5 s, the shared client's timeout. Three attempts at that timeout would cost ~15 s, cutting dispatch throughput from ~10/s to ~3/s during a Google outage and roughly tripling the silent-drop rate. The retry would have made the incident it exists to mitigate measurably worse, with nothing user-visible to say so.

There was no single-flight. Every concurrent dispatch that missed the cache minted its own JWT and called Google independently. Adding retries on top would multiply an outage by the number of in-flight dispatches: N dispatches × 3 attempts = 3N requests aimed at a service already failing.

Single-flight is therefore not a separate optimisation to defer. Without it the retry is a load amplifier pointed at a struggling dependency.

What changed

  • Serialised refresh. A Mutex around the refresh, with a cache re-check after acquiring it, so one exchange serves every waiter. Worst case goes from ~15 s per task to ~6 s shared.
  • Bounded retry. Three attempts, 2 s per attempt (tighter than the shared client's 5 s), exponential backoff with full jitter. The jitter is not about self-contention — single-flight already caps this process to one exchange — but about several instances recovering from the same outage in lockstep.
  • Fail fast on 4xx. A 400 means wrong credentials, clock or scope; repeating the identical request cannot fix any of them and only burns the permit. 429 is the exception, being explicitly a retry signal.
  • build_jwt split from exchange_with_retry, so the transport policy is testable without RSA key material. Credential handling and transport behaviour fail for unrelated reasons and now read that way.

Tests

Eight new, with exact hit-count assertions so an over- or under-eager retry both fail:

  • 503 → 200 succeeds; 429 → 200 succeeds
  • Three consecutive 503s stop at exactly OAUTH_MAX_ATTEMPTS
  • A 400 is attempted exactly once
  • A successful exchange populates the cache; a token within 60 s of expiry is treated as stale
  • Backoff grows and stays inside its jitter bounds

The one worth reading is worst_case_retry_budget_stays_close_to_the_pre_retry_cost. It asserts a property rather than a function: it composes the constants and fails if a later change raises the attempt count or the per-attempt timeout past the point where an outage starts costing more notifications than no retry at all. That regression would otherwise be invisible — the symptom is dropped pushes and a warn! with no pubkey in it.

Not covered by tests

Single-flight itself is verified structurally, not by test. Driving the full refresh path needs RSA key material, and both ways to get it are worse than the gap: committing a private key to a repo whose history already includes a leaked SERVER_PRIVATE_KEY, or moving JWT signing outside the lock so every concurrent caller signs one even when a single exchange runs — which is part of what the lock avoids. The guarded region is three lines and the policy it protects is covered. Noted on the issue rather than left implicit.

docs/architecture.md now describes both behaviours and why they are bounded.

Closes #9

@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 31 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 2b1b4556-a91a-4c5c-84c8-6a48a4d34db1

📥 Commits

Reviewing files that changed from the base of the PR and between 24769fa and b7fae35.

📒 Files selected for processing (2)
  • docs/architecture.md
  • src/push/fcm.rs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@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: e9b98410db

ℹ️ 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 thread src/push/fcm.rs Outdated
Comment thread src/push/fcm.rs Outdated
Single-flight only serialised the cohort waiting on the refresh lock. With
Google still down, each waiter in turn found the cache empty and ran its own
full retry sequence, so 50 queued dispatches became 150 requests aimed at a
service already failing, and the last caller waited roughly 50 retry budgets
while holding an /api/notify permit. Saturating that pool drops dispatches
silently, which is the outcome the retry exists to avoid.

The lock now guards the last failed refresh and every caller arriving within
a 10 s window fails with the same cause. The window is longer than the
worst-case retry budget, so the cohort cannot immediately re-probe, and
negligible against the ~1 h token lifetime, so recovery is not delayed.
A connection that times out or resets after the response headers arrive
fails at the body read, not at send(). Both cases were classified alongside
malformed JSON and failed without a retry, dropping a notification a second
attempt could still have delivered.

Reading the body and parsing it are now separate steps. A transport failure
is retryable like any other network error, while a body that arrived in full
and does not parse stays terminal, since repeating the request cannot repair
it. The new test drives a raw socket that closes mid-body and asserts the
exchange takes exactly two connections.

@ermeme ermeme 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.

Approved

Re-reviewed the current head and the prior feedback. The refresh now single-flights both successful and failed OAuth exchanges: waiters reuse a fresh cached token or the short failure cooldown, preventing an outage from serially multiplying retry budgets while notify permits are held. Network failures while reading a successful response body are also retried, while complete malformed bodies and non-429 4xx responses fail fast.

Verified locally on this head: cargo fmt --all -- --check, cargo test --locked (61 passed), and cargo clippy --locked --all-targets --all-features -- -D warnings. I also merged the current live main tip locally without conflicts and ran its combined suite successfully (98 passed). Current GitHub checks are green.

@AndreaDiazCorreia
AndreaDiazCorreia merged commit 84d45b1 into main Sep 1, 2026
4 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.

[P1] [resilience] Retries / circuit breaker for FCM OAuth2

1 participant