Skip to content

Handle EDGAR rate-limit blocks and spread concurrent retries - #319

Merged
sroussey merged 5 commits into
mainfrom
claude/sec-edgar-rate-limit-wsd0ir
Aug 22, 2026
Merged

Handle EDGAR rate-limit blocks and spread concurrent retries#319
sroussey merged 5 commits into
mainfrom
claude/sec-edgar-rate-limit-wsd0ir

Conversation

@sroussey

Copy link
Copy Markdown
Contributor

Summary

Hardens SEC fetch resilience against EDGAR's rate-limit blocks and prevents thundering-herd retry storms. EDGAR serves rate-limit blocks as both 429 and 403 interstitials; the 403 was treated as a permanent error and never retried or signalled the cluster cooldown, leaving the sweep firing at full rate and renewing the block. Additionally, concurrent retries were re-issued in lockstep, causing transient failures to spike the request rate and trigger the very block they were recovering from.

Key Changes

  • EDGAR 403 interstitial translation (edgarBlockResponse.ts): Translates EDGAR's rate-limit 403 interstitial to 429 at the transport seam so it flows through existing retry/cooldown logic. Distinguishes it from the other 403 EDGAR serves (User-Agent misconfiguration) by matching the body text, ensuring the misconfiguration still fails fast.

  • Escalating cooldown ladder (secFetchThrottle.ts): Replaces the flat 60s default with a three-rung ladder (5s → 60s → 600s) that probes whether a block is genuine before applying the full penalty. A first overshoot costs seconds rather than ten minutes, and the ladder only escalates when a retry after a completed cooldown is blocked again. Coalesces simultaneous blocks from a fleet into one trip.

  • Retry spread (SecFetchJob.ts): Adds uniform spread over two drain windows (ceil(maxConcurrent / maxPerSec) * 2s) to every retry, not just blocked ones. Removes jitter from backoffDelay itself (which was concentrating rather than spreading when combined with the spread), making backoff deterministic and leaving de-synchronization to the spread alone.

  • Cooldown signalling before retry decision: Signals the cluster cooldown before deciding whether to retry the job, so blocks arriving on a job's last attempt still pause the cluster. Blocked jobs sleep the applied cooldown rather than the ordinary backoff, since the cluster sentinel gates dispatch and this job is already dispatched.

  • Test coverage: Comprehensive test suite covering rate-limit detection, escalation ladder behavior, fleet coalescing, quiet-period reset, and retry spreading across concurrent failures.

Notable Implementation Details

  • The cooldown ladder is module state shared across callers, so a block arriving while a cooldown is in force returns the remaining time without escalating — only a block that survives a completed cooldown is new evidence.
  • Quiet time that resets the ladder is anchored on the END of the last cooldown, not the block that caused it, so waiting out a full ban is not itself counted as the clean period that earns a reset.
  • translateEdgarBlockResponse deliberately synthesizes no Retry-After header; EDGAR's ten-minute figure describes the ban it escalates to, not the first overshoot, so stating it would bypass the ladder entirely.
  • The spread is added on top of any capped wait, so a re-issue lands within one drain window after the ceiling.

https://claude.ai/code/session_01FL82qT6KLmfFP6vM2MqKgm

claude added 5 commits August 22, 2026 04:53
…t it out

Exceeding EDGAR's 10 req/s answers with a 403 carrying an HTML interstitial at
least as often as with a 429. A 403 maps to HTTP_CLIENT_ERROR, so the block
arrived as a permanent client error: never retried, and — since the cooldown was
signalled only after the retry gate, on a 429 — never pausing the cluster. SEC's
guidance is that requests made during the time-out extend it, so the sweep kept
firing at full rate for the whole window and renewed the block. The failure was
self-sustaining rather than transient, which is why it recurred instead of
passing.

- Re-label the interstitial as the 429 it describes, at the SafeFetch seam. The
  status is the only thing the fetch layer carries forward (the error message
  reads a {message} out of a JSON body and drops the rest), so an origin that
  explains itself in HTML loses its reason before any caller sees it. Narrow on
  purpose: sec.gov, 403, and only when the body matches — the other 403 EDGAR
  serves is a User-Agent rejection that shares a headline but not the rate
  sentence, and must keep failing fast.
- Signal the cluster cooldown before the retry decision. A block landing on a
  job's last attempt is the same evidence as any other, and under a sustained
  block that is most of them.
- Sleep the applied cooldown instead of the ordinary backoff. The sentinel gates
  dispatch and an already-started job never re-consults the limiter, so a <=30s
  backoff put every in-flight request back on the wire inside the penalty
  window. Jitter spreads the wake-ups so they cannot re-trip the ceiling.
- Raise the default cooldown from 60s to EDGAR's stated 600s. Observed blocks
  carry no Retry-After, so it is the only thing sizing the wait, and a minute
  resumed the cluster nine minutes early into a live block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FL82qT6KLmfFP6vM2MqKgm
…ptured shape

A captured EDGAR 429 confirms the interstitial is served under 429 as well as
403, carries no Retry-After, and has an empty reason phrase (HTTP/2 carries
none) — and that the ban is IP-wide, refusing an ordinary browser on the same
IP mid-sweep.

That reorders the diagnosis. The 429 path was already retryable and already
armed the cluster cooldown, so it looked handled; but a 60s cooldown against a
600s penalty, plus every in-flight job retrying on its own <=30s backoff inside
a live window, was on its own enough to keep renewing the ban. The 403
translation is hardening, not the load-bearing fix. Say so, and note that the
absent Retry-After is what makes the cooldown default the only thing sizing the
wait.

Model the bare-429 regression test on the captured response rather than an
invented one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FL82qT6KLmfFP6vM2MqKgm
…read

A concurrency bound is not a rate bound. ConcurrencyLimiter does cap
simultaneity — its token is held until the job reaches a terminal state, retries
included — but 16 requests are fine spread over two seconds and a violation
arriving in one tick, and a retry re-issues from inside the job, downstream of
every limiter. A shared upstream failure produces exactly that tick: every
in-flight job fails on the same event and computes the same delay, so a
transient 5xx re-issued a full fleet inside 500ms (~32/s) and tripped the very
block it was recovering from. Spread every retry, not just a blocked one.

Two things that read fine and were not:

- backoffDelay jittered too, so the delay was a sum of two independent uniforms
  — which CONCENTRATES toward the middle rather than spreading. Measured at 11/s
  against an 8/s ceiling. It is now deterministic; retrySpread is the single
  jitter source, uniform over a known window.
- The cap was applied to the sum of wait and spread, which ate the spread
  whenever the base already sat at the ceiling — the default block case exactly,
  a 600s cooldown against a 600s cap. So the anti-herd spread introduced in the
  previous commit did nothing in the case it was written for. Cap the wait, then
  add the spread.

The rate test drives a uniform sweep in place of Math.random so it asserts the
spreading formula rather than one draw's luck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FL82qT6KLmfFP6vM2MqKgm
…the first trip

EDGAR throttles the offending requests when a burst clears 10 req/s and
escalates to the ~10-minute IP block only if the caller keeps pushing. Treating
the first 429 as the ban stopped the CLI for ten minutes over a condition a few
seconds of quiet clears — and the budget is per IP, so the overshoot may not
even be all ours: an ordinary browser tab on the same address spends from it.

Walk a ladder (5s -> 60s -> 600s) instead, concluding we are genuinely banned
only once a retry AFTER a completed cooldown is blocked again. Three rungs reach
the full penalty in ~65s: each probe costs a round of requests and requests sent
during a real ban extend it, so more rungs are gentler on a false alarm and
worse on a true one.

Two properties the policy needs:

- A fleet blocking at once is ONE trip. Every in-flight job reports the same
  block, so escalating per caller would climb SEC_FETCH_MAX_CONCURRENT rungs on
  the first overshoot and land on ten minutes immediately — precisely what this
  removes. A block arriving inside a live cooldown returns the REMAINING time
  and does not move the ladder.
- The quiet period that resets the ladder is anchored on the END of the last
  cooldown, so waiting out a ban is not counted as the clean run earning a reset.

translateEdgarBlockResponse no longer synthesizes Retry-After: 600. That figure
describes the ban, not the first overshoot, and stating it handed every first
trip a ten-minute wait and bypassed the ladder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FL82qT6KLmfFP6vM2MqKgm
…it 429

Two defects in the cooldown path, both found by review.

The cluster pause could move BACKWARDS. The ladder rung is process state while
the sentinel is cluster state, and PostgresRateLimiterStorage writes it with an
unconditional `DO UPDATE SET next_available_at = EXCLUDED`, so a second shard
meeting the same block at rung 0 replaced another shard's 600s pause with its
own 5s one and resumed every shard nine minutes inside a live ban. Read the
sentinel first and write the later of the two, adopting it as this job's wait.

The read goes to the STORAGE sentinel, never RateLimiter.getNextAvailableTime():
that returns the latest of the rate wall, the sentinel, AND the instance's
localBackoffUntilMs — which libs documents as keeping this process's worker from
re-acquiring "without polluting cluster state". Feeding the composite back into
setNextAvailableTime publishes that local hint cluster-wide, so a 5s first-rung
trip taken while local backoff sat at its 60s ceiling paused every shard for a
minute. Verified: with the composite read, the first trip returns 60000 instead
of 5000.

Read-then-write is not atomic across processes, so this narrows a systematic
clobber to a rare one. Closing it properly wants GREATEST(...) in the storage
upsert, which is a @workglow/postgres change.

Separately, a `Retry-After: 0` means "retry now" and is not evidence of a ban:
it set cooldownUntil = now, which is already past for the next caller, so a
fleet handed one climbed a rung per in-flight job and landed on the top rung
from a response asking for no wait at all. It now climbs no rung and arms no
window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FL82qT6KLmfFP6vM2MqKgm
@sroussey
sroussey merged commit 451a092 into main Aug 22, 2026
1 check passed
@sroussey
sroussey deleted the claude/sec-edgar-rate-limit-wsd0ir branch August 22, 2026 06:15
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