Repository navigation
feat: origin outages: per-client backoff, a bounded pass-through wait, no strikes for broad failure (plugin v0.109.0, browser v1.43.0) - #262
Conversation
…ikes for broad render failure; v0.109.0 A customer deployment had an origin outage in which crawler misses were held for as long as the origin took to give up, and every page that came due took failure strikes it did not earn. Two changes, each keyed on the plugin's own requests rather than a node-wide breaker: - origin.passthroughDeadline (off by default): a crawler request that goes to the origin waits at most `timeout` for the origin's response headers, then gets `status` (504, or 503 with Retry-After), empty and no-store. The clock covers connecting too and stops at the headers, so a streaming body is never cut. Off by default because a CDN that fails over on Harper's 5xx makes the crawler wait twice. Health line: `originDeadline` (a subset of `originUnreachable`), node and per route. - render.failureRetry.broadFailure (on): while at least `failureShare` of a route's results on this node failed in the last `window` (with at least `minResults`), a failed render on that route counts no strike and retries after a flat `retryAfter` (30 min, never past its cadence), releasing its lease. `maxDuration` (6 h) caps a spell so permanently broken pages cannot keep it on. Health line: `failures` section; queue_health series `broad_failure_retry`. The console's contract test waives the series (a Queue-view tile is a follow-up). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… navigations; v1.43.0 Each worker judges its own navigations once per `originBackoff.windowMs` (60 s). A failed navigation is one page.goto threw for (a timeout, a refused or reset connection) or whose document came back 408, 429 or 5xx; a 404 or a redirect is the origin answering. A window of at least `minNavigations` (10) with at least `failureShare` (0.5) failed halves the share of `concurrency` in use, never below one slot; a thin sample that is still failing holds; any other window adds `recoverStep` (0.25) back. The claim size follows the same share, so a worker on fewer slots holds no leases it cannot start. Logged on each cut and step back; the stats line carries `originBackoff` (answered, failed, share, slots). `originBackoff.enabled: false` turns it off. Settings that would misbehave throw at startup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…efore a navigation error (self-review) The renderer aborts its own navigation signal on a document of 400 or more; were page.goto to reject on that abort, a 404 would have counted as the origin failing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces robust origin backoff and broad render failure handling mechanisms to gracefully manage origin outages. In the browser package, an OriginBackoff class is added to scale down render slots and job claim limits when a worker detects high navigation failure rates. In the plugin package, a broadFailure utility is implemented to track failures per route using shared memory, allowing failed renders during broad outages to retry without counting strikes. Additionally, a passthroughDeadline option is introduced to bound crawler wait times. The review feedback suggests improving the robustness of elapsed time comparisons against potential NaN values in both broadFailure.js and OriginBackoff.ts by using the !(elapsed < interval) pattern.
| } | ||
|
|
||
| if (Atomics.load(view, state + 1) === 1) return { broad: false, capped: true, results, failed, route }; | ||
| if ((minute - brokeAt) * MINUTE_MS >= maxDuration) { |
There was a problem hiding this comment.
When performing elapsed time comparisons where a timestamp might be null, undefined, or unparseable (resulting in NaN), use the pattern !(elapsed < interval) instead of elapsed >= interval to ensure NaN values fail the inequality and correctly fall through to the fallback/unconditional branch (failing closed).
| if ((minute - brokeAt) * MINUTE_MS >= maxDuration) { | |
| if (!((minute - brokeAt) * MINUTE_MS < maxDuration)) { |
References
- When performing elapsed time comparisons where a timestamp might be null, undefined, or unparseable (resulting in NaN), use the pattern !(elapsed < interval) instead of elapsed >= interval to ensure NaN values fail the inequality and correctly fall through to the fallback/unconditional branch (failing closed).
There was a problem hiding this comment.
Adopted in d696582: the cap check is now !((minute - brokeAt) * MINUTE_MS < maxDuration), so a reading that is not a number caps the spell (strikes count, the pre-existing behavior) instead of excusing failures on the route for good.
🤖 Addressed by Claude Code
| private roll(): void { | ||
| const { windowMs } = settings.originBackoff; | ||
| const elapsed = this.now() - this.windowStart; | ||
| if (elapsed < windowMs) return; |
There was a problem hiding this comment.
When performing elapsed time comparisons where a timestamp might be null, undefined, or unparseable (resulting in NaN), use the pattern !(elapsed < interval) instead of elapsed >= interval to ensure NaN values fail the inequality and correctly fall through to the fallback/unconditional branch (failing closed).
if (!(elapsed < windowMs)) {References
- When performing elapsed time comparisons where a timestamp might be null, undefined, or unparseable (resulting in NaN), use the pattern !(elapsed < interval) instead of elapsed >= interval to ensure NaN values fail the inequality and correctly fall through to the fallback/unconditional branch (failing closed).
There was a problem hiding this comment.
Changed in d696582, differently: if (elapsed < windowMs) return; already let NaN fall through, and falling through was the problem (windowStart became NaN for good). A non-finite elapsed now restarts the window at the current reading, and a new test covers it.
🤖 Addressed by Claude Code
…arts a backoff window (review of #262) broadFailure: a NaN elapsed now fails toward capped (strikes count, the pre-existing behavior) rather than leaving the route broad for good. OriginBackoff: a NaN elapsed restarts the window at the current reading instead of making windowStart NaN permanently. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review fixes pushed in d696582:
Both inline threads have a reply; left open for the reviewer to resolve. 🤖 Addressed by Claude Code |
Why
A customer deployment had a two-hour origin outage: the origin answered most pass-through fetches with a 504, slowly. Cache serving held, and no failed render was stored, so correctness was fine. Our own traffic did not back off, though:
Agentwaits up to 300 s for headers).Design: each client backs off on its own requests
There is no node-wide breaker. The outage was narrower than the origin: one kind of page failed while product pages answered normally. A breaker fed by crawler misses (
origin_fetch) would have tripped hard and stopped the change probe and the renders that were still succeeding, giving up price-change detection for a small cut in our own load. So every source of origin traffic judges the errors of its own requests:abortAfterDistress(50) distressed callsChanges
Plugin v0.109.0
origin.passthroughDeadline(timeout0 = off,status504 | 503,retryAfter2 min). A crawler request that goes to the origin waits at mosttimeoutfor response headers, then getsstatus, empty andcache-control: no-store; a 503 carriesRetry-After. The clock runs from the start of the fetch (connecting included) to the headers, through anAbortControllercleared on headers, so a body already streaming is never cut. The bot handler writes one summary warning a minute instead of an error per request.Off by default, and the status is configurable: a CDN that fails over to the origin on Harper's 5xx makes the crawler wait the deadline plus the origin's own time, and the right status depends on what the CDN does with each. Health line:
originDeadline, node and per route, a subset oforiginUnreachable(neverown5xx).render.failureRetry.broadFailure(on;window10 min,minResults20,failureShare0.5,retryAfter30 min,maxDuration6 h). Newutil/broadFailure.jskeeps a per-route ring of one-minute buckets in node shared memory (getSab), so every worker's results land in one window. While a route is broad, a failed render on it:min(retryAfter, cadence)out with its change mark kept, and releases its lease, unlike the fast lane, which would hold a lease per failing page for the whole outage;Judged per route because outages often hit one kind of page: judged node-wide, a failing route that is a minority of renders never crosses the threshold.
maxDurationcaps a spell because pages retried every 30 min come back far more often than healthy ones on their cadence, so pages broken for good could otherwise hold a route's share up by themselves. Past the cap, strikes count again until the share falls.Observability: a warning when a spell starts and when it is capped, a notice when it ends; health line
failuressection (windowresults/failed,broad,capped, per-route counts);queue_healthseriesbroad_failure_retry.Browser v1.43.0
originBackoff(on;windowMs60 s,minNavigations10,failureShare0.5,recoverStep0.25). NewOriginBackoff.ts, one per process, read by the worker's slot wait and the consumer's claim size. A failed navigation is onepage.gotothrew for, or whose document arrived 408, 429 or 5xx; a recorded status decides before any error (the renderer aborts its own signal on a 4xx document). Per closed window:minNavigationsand at leastfailureSharefailed: halve the share ofconcurrencyin use, never below one slot (the trickle that notices recovery);recoverStep; an empty window counts as healthy.awaitSlotnow loops, since one render finishing need not bring the in-flight count under a cut-down limit. Stats line:originBackoff(answered,failed,share,slots). Invalid settings throw at startup.Deliberately not changed
expiresAt + page.swrTtlstays refused (bot/user parity, seefailureRetry); whether to offer it is a customer decision still open.Rollout
prerender-v0.109.0,v1.43.0); neither needs the other.broadFailureandoriginBackoffare on by default;passthroughDeadlineis off until the CDN question is settled per deployment.Tests
test/botServePath.test.js(real sockets: 504 at the deadline with the origin request torn down; 503 withRetry-Afterrounded up; a body streaming past the deadline arrives whole; off by default waits as before; health filing);test/broadFailure.test.js(threshold needs both counts; routes judged apart; window forgets; ring reuse;maxDurationcap and reset; health section; off);test/renderQueueVariants.test.js(no strike, lease released,retryAfterdue time; no escalation pastfastRetries; change mark kept; one-device row keeps its lease; under the threshold strikes as before).test/originBackoff.test.ts(verdicts; halve/hold/recover curve on a fake clock; idle recovery; off; settings validation; a realworker.runwhose peak concurrency drops from 4 to 2 to 1 and ramps back).missCause.test.jswaits a fixed 5 ms for detached work and fails intermittently under full-suite load. Run alone, 36 times in parallel batches of 12:mainfailed 2, this branch 1.client → proxy → plugin(thesamplingadmin route is not exposed) and a probe-view assertion that expects05:05 UTC, broken by the daylight-saving change. Both fail identically onmainat af55ca9.queue_healthcontract test waivesbroad_failure_retrywith a reason; a Queue-view tile is a follow-up.🤖 Generated with Claude Code