fix(pkg): fetch the IRMA JWT verification key lazily instead of at startup#240
Conversation
…artup
The Jwt auth middleware fetched {IRMA_SERVER}/publickey during worker
initialization and failed the whole server when the IRMA server was
unreachable at that moment ("can not start server service"), creating a hard
restart-order dependency. The unbounded reqwest::get could also stall worker
startup on a hanging connect.
The key now lives in a JwtKeyCache that fetches it (with a 5s timeout) on the
first JWT request: startup does no network I/O, and while the IRMA server is
unreachable JWT requests degrade to 503 UpstreamError instead of the PKG never
coming up.
Fixes #233
|
Thanks for the detailed writeup! I'm on it — kicking off a review of PR #240 now, checking the |
There was a problem hiding this comment.
Approving — clean, well-scoped fix.
Startup no longer does network I/O, so a briefly-unreachable IRMA server at boot no longer crashes or hangs the PKG; JWT requests degrade to 503 (UpstreamError) and self-recover once IRMA is back. The RefCell-based lazy cache is sound: no borrow is held across an .await (the condition Ref is dropped before fetch().await, and decode() is synchronous). Fixes #233, conventional PR title, and a committed regression test (test_jwt_auth_starts_when_irma_unreachable). Build/tests confirmed locally (cargo test -p pg-pkg 54/54, fmt + clippy clean).
One non-blocking nit inline; key rotation/refresh is already tracked in #234.
|
|
||
| /// Decode and validate a JWT, fetching the verification key on first use. | ||
| async fn decode(&self, jwt: &str) -> Result<Claims, crate::Error> { | ||
| if self.key.borrow().is_none() { |
There was a problem hiding this comment.
Nit (non-blocking): while the IRMA server is unreachable, decode() re-runs fetch() — up to the full 5s timeout — on every JWT request, with no negative caching, backoff, or in-flight de-duplication. Under a burst of JWT traffic during an IRMA outage this can tie up each worker for ~5s per request. This is the intended self-recovery tradeoff, and key caching/refresh is deferred to #234, so just flagging in case a short negative-cache/backoff is wanted here too.
|
Reviewed and approved 👍 — nice clean fix for the cold-start race. I checked that the Left one non-blocking nit inline: during an IRMA outage, Full review: #240 (review) |
While the IRMA server is unreachable, every JWT request re-ran the key fetch (up to the full 5s timeout), so a burst of JWT traffic during an outage could tie each worker up in back-to-back fetch timeouts. Failed fetches are now negatively cached for 3s: requests inside the window fail fast with 503 and fetching resumes automatically afterwards.
|
Addressed the nit, /dobby: failed key fetches are now negatively cached ( |
|
Thanks for the update! I'm on it — kicking off a review of PR #240 now, focusing on the negative caching fix and the new |
There was a problem hiding this comment.
APPROVE — clean, well-scoped fix for the cold-start race in #233.
- Startup no longer does network I/O; the key is fetched lazily on the first JWT request with a 5s timeout, so an unreachable IRMA server degrades to 503 instead of killing the worker. ✔
- The follow-up
negatively cache failed JWT key fetchescommit correctly collapses the back-to-back (sequential) retry storm during an outage into one fetch per 3s window. ✔ - Both regression tests are committed; prior verification run: pg-pkg 55 tests pass (incl. both new tests),
cargo fmt+ build clean. Fixes #233closing keyword present; title is conventional-commit compliant (checked bypr-title.yml).
One optional, non-blocking note inline on the negative-cache concurrency window. Good to merge.
|
|
||
| /// Fetch the key and store it, negatively caching failures so an IRMA | ||
| /// outage doesn't turn every JWT request into a full fetch timeout. | ||
| async fn fetch_and_store(&self) -> Result<(), crate::Error> { |
There was a problem hiding this comment.
Non-blocking nit (carried over from the earlier review — the negative cache handles most of it): the "one upstream fetch per outage" guarantee holds for sequential requests and for anything arriving after the first fetch fails, but not for a truly concurrent burst during the very first in-flight fetch. last_failure is set only after self.fetch().await completes (line 292) while key stays None, so on a single-threaded worker, JWT requests that arrive while the first ~5s fetch is still awaiting each pass the key.is_none() gate (decode, line 305) and the backoff check here, and start their own fetch — a small thundering herd of in-flight timeouts, not one.
The new test awaits each decode sequentially, so it can't exercise this. It degrades safely to 503 regardless, so this is fine to leave — but if the stronger "exactly one fetch across a burst" guarantee is intended, marking an in-flight/pending state before awaiting fetch() would close the gap. Could also fold into #234 when you add key refresh.
|
Nice, that closes it out — the negative-cache backoff is exactly what was needed for the retry-storm case, so I approved PR #240. Tests, fmt, and build are all clean. Left one optional, non-blocking note: a truly concurrent burst during the very first in-flight fetch can still each start their own fetch (since |
Negative caching only kicks in after a failed fetch completes, so requests arriving while the very first fetch was still in flight each started their own — a small thundering herd of fetch timeouts. ensure_key now serializes fetches on an async lock with a double-check: one request does the network I/O, the rest wait and then observe either the fresh key or the fresh negative cache.
|
Addressed the concurrency carry-over too, /dobby: fetches are now de-duplicated in flight on an async lock ( New regression test Also merged forward into #241, where |
|
Thanks for the detailed update, @rubenhensen! I've picked this up and I'm reviewing the concurrency fix (async lock with double-check, the new |
|
Nice work — #240 is already merged (with two approving reviews), and the diff has exactly the de-dup fix you described: the Only thing worth a follow-up (non-blocking): |
Fixes #233 — found by the e2e harness (encryption4all/postguard-e2e) as a CI cold-start race.
Before: the Jwt auth middleware fetched
{IRMA_SERVER}/publickeyduring worker init; if the IRMA server was briefly unreachable, every worker failed and the PKG exited (Error: IO error: can not start server service 0). The unboundedreqwest::getcould also hang worker startup on a stalled connect.After:
AuthMethods::Jwtholds aJwtKeyCachethat fetches the key (5s timeout) on the first JWT request:UpstreamError) and recover on their own once it's backRegression test:
test_jwt_auth_starts_when_irma_unreachable— middleware init against an unreachable IRMA URL must succeed, and the request must fail with 503 (previouslyinit_serviceitself died).Note: the key is still cached forever once fetched — refreshing it on rotation is #234, which I'll stack on this PR.