Skip to main content
Systems Engineering

API Returns 429 Too Many Requests: Fixing Retry Storms with Backoff

How to diagnose persistent HTTP 429 responses and replace synchronised retries with Retry-After handling, jittered backoff and client-side throttling.

Moody seascape with storm clouds looming over rocky coastline and driftwood on sandy beach.

In this guide

Share

#Context

An HTTP 429 response means a server is telling a client that it has sent too many requests in a given period. The status is a signal, not a fault. A well-run API returns it to protect shared capacity. The problem begins when clients answer that signal by retrying at once. Each rejected request becomes several more, and the rejection rate climbs even though the original demand has not changed. This pattern is usually called a retry storm.

This article treats the problem as a closed-loop control issue. The server limits admission. The client decides how to respond to refusal. If the client response adds load, the loop is unstable. Fixing it means changing client behaviour so that refusal reduces load, and then proving that the change works under pressure.

Assumptions are stated here so you can check them against your own estate. We assume you operate or consume an HTTP API that returns 429 under load. We assume you can change the client code or the shared client library. We assume you have a non-production environment in which you can provoke rate limiting safely. We do not assume any particular vendor, gateway or limit value. Limit algorithms, header names beyond Retry-After, and quota scopes differ between products, so confirm them in the vendor documentation listed in the sources for this article.

The supplied source, Microsoft Learn’s operational excellence principles, covers observability, automation, safe deployment and operational readiness. Those themes frame the approach below: measure first, automate the response, deploy the change safely and keep the system recoverable. The source does not describe 429 handling itself, so the protocol-level behaviour described here is general engineering knowledge that a reviewer should confirm against current HTTP specifications and your vendor’s documentation.

#Architecture

A retry storm needs three ingredients: a limit that rejects work, many clients that share that limit, and a retry policy that is fast and synchronised. Remove any one and the storm weakens. You usually control only the third, so the design concentrates there.

The client-side design has four cooperating parts:

  • Server hint handling. If the response carries a Retry-After header, the client waits at least that long before the next attempt for the same limited scope. The header can carry either a number of seconds or an HTTP date, so the parser must accept both.
  • Exponential backoff with jitter. When no hint is present, the delay grows with each consecutive failure up to a cap, and a random component spreads clients apart so they do not return at the same instant.
  • Client-side throttling. A local limiter, such as a token bucket, paces outgoing requests below the known or observed limit so that 429 becomes the exception rather than the steady state.
  • Retry budget. A cap on attempts per request and on the share of traffic that may be retries, so a prolonged refusal ends in a clear failure instead of unbounded load.

The flow below shows how a single request moves through these parts.

Rendering diagram...

Retries also interact with correctness. A request that was rejected with 429 was normally not processed, but a request that timed out may have been. If the operation writes data, retries must be safe to repeat. The related guidance on idempotency keys for exactly-once writes describes how to make repeated attempts harmless, and it matters before you enable any automatic retry on non-read operations.

A powerful storm approaches a rocky beach, creating a dramatic and moody coastal scene.
Photo by Connor Scott McManus on Pexels

#Implementation

Start with diagnosis, because the fix depends on what is actually happening. Collect, from client logs and server metrics, the rate of 429 responses over time, the number of attempts per logical request, the interval between consecutive attempts from the same client, and whether Retry-After was present and respected. Three observations point to a storm. The 429 rate stays high or rises after the original burst has ended. Attempt intervals are short and uniform. Many clients show attempts aligned to the same seconds. If instead 429 responses track genuine demand growth, the cause is capacity or quota, and backoff will only smooth the symptom.

Next, centralise retry logic. Scattered per-call retry loops are the usual origin of storms because each team picks its own policy. Put the policy in one shared client wrapper so that it can be reviewed, tested and changed once.

The delay calculation has a simple shape. For attempt number n, starting at zero, compute a ceiling of min(cap, base multiplied by 2 to the power n). Then choose the actual delay randomly between zero and that ceiling. This is commonly called full jitter. Other jitter variants exist, and the choice is a trade-off between spreading load and keeping latency predictable. If a Retry-After value is present, treat it as a floor: the delay is the larger of the server hint and the computed value, so the client never returns earlier than the server asked.

The following sketch illustrates the decision logic. It is illustrative and has not been executed against any specific API.

1import random, time
2
3def delay_seconds(attempt, retry_after, base=0.5, cap=30.0):
4    ceiling = min(cap, base * (2 ** attempt))
5    computed = random.uniform(0, ceiling)
6    if retry_after is not None:
7        return max(retry_after, computed)
8    return computed
9
10def call_with_retry(send, max_attempts=5):
11    for attempt in range(max_attempts):
12        response = send()
13        if response.status != 429:
14            return response
15        hint = parse_retry_after(response.headers.get("Retry-After"))
16        time.sleep(delay_seconds(attempt, hint))
17    raise RuntimeError("rate limited: retry budget exhausted")

The helper parse_retry_after must handle both formats. A non-negative integer is a delay in seconds. An HTTP date is converted to a delay relative to the current time and clamped at zero. Treat an unparseable value as absent rather than failing, and consider an upper bound on how long you will honour so a faulty header cannot stall a worker indefinitely.

Then add client-side throttling. A token bucket refilled at a rate below the documented or observed limit keeps the steady-state request rate under the threshold. Where many processes share one quota, a per-process limiter is not enough, because the sum of their rates may still exceed the limit. In that case either divide the budget across instances, or route calls through a shared component that owns the limiter. Be explicit about the scope of the limit, since quotas may apply per key, per user, per address or per endpoint, and the limiter should mirror that scope. Confirm the scope in vendor documentation rather than inferring it.

Finally, bound the retries. Set a maximum number of attempts and a maximum total elapsed time per request. Consider a retry budget expressed as a fraction of recent successful traffic, so that when the server is refusing most requests the client stops amplifying. When the budget is exhausted, fail with an error that names rate limiting, so callers and dashboards can distinguish it from a timeout.

Where a failed call belongs to a multi-step business process, a refusal after earlier steps succeeded needs a defined response. The article on designing compensating sagas for microservices covers how to undo or complete such sequences when a later step cannot proceed.

#Validation

Validate in an isolated or non-production environment, and confirm the product version and your permissions before changing anything. Success must be observable, so define it before you start.

  1. Reproduce the baseline. Drive load from a test client until 429 appears, and record the 429 rate, attempts per request and inter-attempt intervals. Without a baseline you cannot show improvement.
  2. Test Retry-After handling. Use a stub server that returns 429 with a fixed Retry-After in both formats. Pass condition: no retry is sent before the hinted time elapses.
  3. Test jitter. Start many simulated clients at the same instant against a stub that always returns 429. Pass condition: retry timestamps spread across the window instead of clustering on a few instants.
  4. Test the cap and budget. Make the stub refuse indefinitely. Pass condition: each request stops after the configured attempts and surfaces a rate-limit error, and total retry volume stays bounded.
  5. Test throttling. Run steady load with the limiter enabled. Pass condition: the 429 share falls and sustained throughput stays near the configured rate rather than collapsing.
  6. Test recovery. Lift the refusal on the stub. Pass condition: clients resume normal rate without a synchronised surge.

These checks reflect the operational excellence emphasis on observability and automation: the tests should run automatically and the metrics should exist in production before the change ships, so that the same signals prove the change there.

#Failure modes

  • Jitter without a cap or budget. Delays are spread but the total attempt count is unbounded, so load persists. Add attempt and elapsed-time limits.
  • Layered retries. A client library, a service mesh and an application each retry, multiplying attempts. Count attempts end to end and keep retry in one layer where possible.
  • Per-process limiter, shared quota. Each instance stays under its own rate, yet the fleet exceeds the quota. Share the budget or centralise the limiter.
  • Trusting a malformed hint. An enormous Retry-After value parks workers. Clamp the maximum wait and alert when the clamp triggers.
  • Retrying unsafe writes. A timeout followed by a retry duplicates an effect. Require idempotency before retrying non-read operations.
  • Synchronised deployment. Restarting many clients together recreates a burst at start-up. Stagger rollouts and add start-up jitter.
  • Capacity, not behaviour, is the cause. Demand genuinely exceeds the quota. Backoff only delays failures, so raise the quota, reduce demand or shed low-priority work.
A hand holding a shiny metal wrench, ideal for repair and mechanic themed visuals.
Photo by svetlana photographer on Pexels

#Security

Rate limits are a security boundary as well as a capacity control, so treat the client change with the same care as any other. Do not attempt to evade a limit by rotating credentials, addresses or identities. Doing so can breach the provider’s terms and defeats a control that exists for abuse prevention. Where more throughput is legitimately required, request a higher quota through the provider.

Apply least privilege to the test setup. Use dedicated test credentials with the narrowest scope, never production keys or private production data, and keep them out of logs and example code. Be careful that retry logging does not record authorisation headers or request bodies containing personal data. Log the status, attempt number, delay and a correlation identifier instead.

Load generation can itself be disruptive. Run it only against systems you own or have written permission to test, and confirm in advance that it cannot affect shared tenants. The residual risk after this change is that a poorly tuned policy slows legitimate recovery or hides a capacity shortfall, which is why the monitoring described below stays in place.

#Recovery

Treat the client retry policy as a deployable change with a defined way back. Ship it behind a configuration flag or as a versioned library release so it can be reverted without a rebuild, and roll it out to a small share of traffic first.

Define stop conditions before rollout. Halt and revert if the 429 rate rises above the baseline you recorded, if request latency or error rates for end users worsen beyond your agreed threshold, or if queues of waiting work grow without draining. Rollback means returning the flag or library version to the previous setting, then confirming that attempt rates and 429 counts return to the earlier profile. Because the earlier policy may itself have been the cause of storms, revert only to a state you have already measured, and pair rollback with a temporary reduction in client concurrency if refusals persist.

During an active storm, the safest immediate actions are those that reduce load: lower client concurrency, pause non-essential batch jobs, and stagger restarts. If the server side is yours, consider whether to raise limits temporarily, but only through your normal change process, because loosening protection can move the failure downstream. If the situation does not respond to these measures, escalate to the owner of the API and the platform on-call rather than improvising changes to shared infrastructure.

#Retry handling readiness checks

Before declaring the work complete, confirm the following in the environment where the change will run.

  • Dashboards show 429 rate, attempts per logical request, retry share of total traffic and wait-time distribution, with alerts on sustained elevation.
  • Tests for both Retry-After formats, jitter spread, attempt caps and recovery pass in automation.
  • Retry occurs in one layer, and write operations are idempotent before they are retried.
  • The rollback switch has been exercised once in non-production and its effect confirmed in the metrics.
  • Vendor documentation has been checked for header names, quota scope and any version-specific behaviour, and the findings recorded.

The next safe decision depends on the evidence. If the 429 share fell and recovery was smooth, widen the rollout in stages while watching the same signals. If 429 persisted at a steady level despite correct behaviour, the issue is capacity or quota, and the decision moves from client tuning to a conversation about limits, priorities and demand.

About this page

Credited to

Marcus ThorneKBY Technologies editorial account, not an individual author

Dates

Published

Sources

This page lists no external sources.

Corrections

No corrections are listed for this page. Some review sweeps corrected pages without naming each one; those are in the corrections log.

Reader Interaction

Comments

Add a thoughtful note on API Returns 429 Too Many Requests: Fixing Retry Storms with Backoff. Comments are checked for spam and held for moderation before appearing.

Loading comments...

Discover more

Lexicon Definitions

Learn More About KBY

Was this useful?

Reports go to the site owner and are used to correct pages. Corrections are listed at /corrections.

Engineering insights, direct to you.

Receive the latest Systems Engineering tutorials, production guides, Engineering Labs and operational best practices.