Skip to content

feat: fail fast locally on endpoints answered with 429 - #45

Draft
gracehopper-vt wants to merge 6 commits into
feat/expose-retry-afterfrom
feat/retry-and-fail-fast-on-429
Draft

gracehopper-vt wants to merge 6 commits into
feat/expose-retry-afterfrom
feat/retry-and-fail-fast-on-429

Conversation

@gracehopper-vt

@gracehopper-vt gracehopper-vt commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Stacked on #44 (base branch feat/expose-retry-after), which exposes Error.RetryAfter.

Today vt-go returns an error on every 429, but nothing stops a loop in user code from sending the same call again and again while the quota is exhausted. With this PR the client stops sending requests to an endpoint that has already been rejected.

  • No automatic retries. A 429 is returned to the caller right away, as it is today.
  • Per-endpoint local block. When the 429 comes with a Retry-After header, the client remembers it. Until then, requests to the same endpoint fail locally with a QuotaExceededError Error and the remaining RetryAfter, without reaching the API.
  • What counts as an endpoint. The key is the HTTP method plus the path, with object IDs replaced by *. Any path segment that isn't only [a-z_] is treated as an ID. For example, GET /files/* and GET /files/*/download are separate endpoints. If a segment is misclassified, the block only gets more specific, never wider.
  • Why per endpoint. There is no hard-coded endpoint→quota table and no server changes are needed. A block on one endpoint can't block unrelated endpoints (e.g. an exhausted retrohunt quota doesn't block file lookups). A client cycling over many endpoints sends at most one request per endpoint per window.
  • Without Retry-After, the endpoint is blocked for 60 seconds (e.g. rate limits at the edge/Cloud Armor, which answer 429 with a generic HTML body and no header), except for QuotaExceededError: the API only omits the header for quotas that don't reset over time (storage, object counts), where blocking makes no sense.
  • State. The block list is an in-memory map per client (mutex-protected), keyed by normalized endpoint, so its size is bounded by the number of endpoints. Expired entries are dropped when a new block is added or when they are checked. Nothing is persisted or shared between processes.

No new client options. This is a behaviour change, so it should go in a minor release with a changelog entry.

Tests

New tests in vt_test.go: no retry on 429, per-endpoint block with Retry-After (other endpoints unaffected), 60s block on a 429 without Retry-After (edge HTML body), no block for QuotaExceededError without it, block expiry, endpointKey cases. go test ./... and go vet ./... pass; gofmt is clean on the changed files.

gracehopper-vt and others added 5 commits October 3, 2026 13:13
Requests answered with 429 are now retried up to 2 times by default
(WithMaxRetries), waiting what the Retry-After header asks for when it's
at most 60 seconds (WithMaxRetryWait), or using exponential backoff with
jitter if the header is missing.

When the server asks to wait longer (e.g. an hourly or daily quota is
exhausted), the error is returned and the client remembers it: requests
to the same endpoint family fail locally with the same QuotaExceededError
until Retry-After has elapsed, without reaching the API. This can be
disabled with WithQuotaFailFast(false).

Requests whose body can't be re-sent (streamed uploads) are not retried,
but they honor the local block.

Co-authored-by: Oscar Torreno <torreno@google.com>
Drop expired blocks whenever a new one is added and cap the map at 256
endpoint families (evicting the block that expires first), so it can't
grow unbounded even if a caller hits many different paths.

Co-authored-by: Oscar Torreno <torreno@google.com>
Most API endpoints share the general API requests quota, which is
exhausted for all of them at once, so a 429 there now blocks every
endpoint using that quota. Intelligence downloads (file/behaviour
downloads, zip files), Intelligence searches and private scanning have
their own quotas and are blocked independently.

With a fixed set of four families the bounding logic is no longer
needed and has been removed.

Co-authored-by: Oscar Torreno <torreno@google.com>
Drop automatic retries: a 429 is returned to the caller right away. When
it comes with a Retry-After header, further requests to the same endpoint
(method + path with object IDs replaced by "*") fail locally until that
time has passed, so a client looping over the API can't keep hitting it.
This replaces the hard-coded quota family table, and the WithMaxRetries,
WithMaxRetryWait and WithQuotaFailFast options are removed.

Co-authored-by: Oscar Torreno <torreno@google.com>
@gracehopper-vt gracehopper-vt changed the title feat: retry 429 responses and fail fast while a quota is exhausted feat: fail fast locally on endpoints answered with 429 Oct 3, 2026
Rate limits at the edge (Cloud Armor) answer 429 with a generic HTML body
and no Retry-After header, so a client looping over the API wouldn't be
stopped. Block the endpoint for 60 seconds in that case. QuotaExceededError
responses without Retry-After are not blocked, since the API only omits the
header for quotas that don't reset over time (e.g. storage).

Co-authored-by: Oscar Torreno <torreno@google.com>
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