Skip to content

fix: an exhausted bandwidth allowance is not a retryable rate limit - #18

Merged
codemug merged 1 commit into
mainfrom
fix/bandwidth-429-is-not-retryable
Aug 21, 2026
Merged

fix: an exhausted bandwidth allowance is not a retryable rate limit#18
codemug merged 1 commit into
mainfrom
fix/bandwidth-429-is-not-retryable

Conversation

@codemug

@codemug codemug commented Aug 21, 2026

Copy link
Copy Markdown
Owner

The problem

FMP overloads HTTP 429 for two conditions that need opposite handling, and the only thing distinguishing them is the response body:

{"Error Message": "Limit Reach . Please upgrade..."}
{"Error Message": "Bandwidth Limit Reach . Please upgrade..."}

The first is per-minute pacing — it clears in seconds and is worth retrying. The second means the plan's rolling byte allowance is spent, and nothing will succeed until the window rolls over, typically days later.

_handle_response discarded the body and raised FMPRateLimitError for both, so an exhausted allowance entered the transparent-retry arm and looped at 5s/10s/20s per request indefinitely.

Observed impact

A view rebuild in a real deployment sat in that loop for ten hours, logging

Rate limit hit, attempt 3/4; sleeping 20s before retry

while every request in it was guaranteed to fail. Nothing was fetched, nothing was written, and the only signal was a log line that reads like ordinary throttling.

The change

Reads the body on 429 and raises the new FMPBandwidthError when it names a bandwidth limit.

Two deliberate decisions worth review:

  • It is not a subclass of FMPRateLimitError. Subclassing would put it straight back inside the except FMPRateLimitError arm of the retry loop — the exact bug being fixed. The existing loop catches only TimeoutError, ClientError, FMPRateLimitError and FMPServerError, so the new type propagates to the caller, matching the documented intent that "other FMP* exceptions propagate immediately so callers can apply the right per-exception policy."
  • An unreadable body falls back to the rate-limit reading. That costs one retry; the reverse would turn a transient hiccup into a permanent failure.

It is also distinct from the existing FMPBudgetError, which is the harvester's own client-side cap rather than a server-side refusal.

Tests

Covers the split, not just the new class:

  • a bandwidth 429 is attempted exactly once
  • a per-minute 429 still gets its full retries
  • the two exception types are asserted not related by inheritance
  • an unreadable 429 body degrades to FMPRateLimitError

Full tracked suite passes (939 tests); ruff check and ruff format --check clean.

🤖 Generated with Claude Code

FMP overloads HTTP 429 for two conditions that need opposite handling, and the
only thing distinguishing them is the response BODY:

    {"Error Message": "Limit Reach . Please upgrade..."}
    {"Error Message": "Bandwidth Limit Reach . Please upgrade..."}

The first is per-minute pacing and clears in seconds. The second means the
plan's rolling BYTE allowance is spent, and nothing will succeed until the
window rolls over -- typically days.

`_handle_response` discarded the body and raised `FMPRateLimitError` for both,
so an exhausted allowance entered the transparent-retry arm and looped at
5s/10s/20s per request, forever. Observed in a real deployment: a view rebuild
sat in that loop for ten hours, fetching nothing, while every request in it was
guaranteed to fail.

`FMPBandwidthError` is deliberately NOT a subclass of `FMPRateLimitError` --
subclassing would put it straight back inside the `except FMPRateLimitError`
arm, which is the bug. It is also distinct from `FMPBudgetError`, which is the
harvester's own client-side cap rather than a server-side refusal.

An unreadable 429 body falls back to the rate-limit reading: that costs a retry,
where the reverse would turn a transient hiccup into a permanent failure.

Tested for the split rather than just the new class: a bandwidth 429 is
attempted exactly once, a per-minute 429 still gets its full retries, and the
two exception types are asserted not to be related by inheritance.
@codemug
codemug merged commit 4cd626d into main Aug 21, 2026
1 check 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.

1 participant