Retries are a normal part of API client design. When a request fails because of a temporary network problem, timeout, or overloaded service, retrying can give the request another chance to succeed.
The problem starts when retries are added without considering how the server behaves under failure.
If thousands of clients all retry at the same time, a struggling API can suddenly receive several times more traffic than the original request volume. The server becomes even slower, more requests time out, and clients retry again. This creates a feedback loop that can turn a small service problem into a much larger outage.
Retry logic is therefore not simply about deciding how many times to call an API. It is about controlling additional load when the system is already having trouble.
What Happens During a Retry Storm
Consider an API receiving 1,000 requests per second.
Under normal conditions, the service can process them comfortably:
Incoming traffic: 1,000 requests/sec
API capacity: 1,200 requests/sec
Result: Healthy
Now suppose the database becomes slow and API response times increase.
Some requests begin timing out.
If every client immediately retries the failed request, the traffic can increase:
Original requests: 1,000/sec
Retry requests: 800/sec
Total traffic: 1,800/sec
The API is now receiving more traffic precisely when it has less capacity.
More requests cause more resource consumption, which increases latency and timeouts. Those timeouts trigger additional retries.
The resulting cycle can look like this:
Service slows down
↓
Requests timeout
↓
Clients retry
↓
Traffic increases
↓
Service becomes slower
↓
More requests timeout
↓
More retries
This is commonly called a retry storm.
Why Immediate Retries Are Dangerous
A simple retry implementation often looks like this:
for (int attempt = 0; attempt < 3; attempt++)
{
try
{
return await client.GetAsync(url);
}
catch (HttpRequestException)
{
// Retry immediately
}
}
throw new Exception("Request failed.");
The code is easy to understand, but it has an important problem.
If the API is temporarily unavailable, all clients may execute the retry immediately.
Imagine 10,000 application instances making a request at approximately the same time.
If the first attempt fails, those clients can immediately send another request. If that fails again, they send another one.
Instead of reducing pressure on the failed service, the clients increase it.
Retry Count Is Not the Same as Retry Safety
Developers often focus on limiting retries:
Maximum retries: 3
That is useful, but it does not answer the most important questions.
You also need to consider:
When should the retry happen?
How long should the client wait?
Should every error be retried?
Should all clients wait for the same amount of time?
Is the operation safe to repeat?
What happens when the server is overloaded?
Does the server tell the client when to retry?
A client that retries three times immediately can be more harmful than a client that retries five times with proper backoff.
The timing and conditions matter as much as the retry count.
Use Exponential Backoff
A common approach is exponential backoff.
Instead of retrying after a fixed delay, the client waits longer after each failed attempt.
For example:
Attempt 1 → failure
Wait 100 ms
Attempt 2 → failure
Wait 200 ms
Attempt 3 → failure
Wait 400 ms
Attempt 4 → failure
Wait 800 ms
The exact values should depend on the API and workload.
The important idea is that clients should not immediately send another request when the service is already failing.
A simple implementation can look like:
var delay = TimeSpan.FromMilliseconds(200);
for (int attempt = 1; attempt <= 3; attempt++)
{
try
{
return await client.GetAsync(url);
}
catch (HttpRequestException) when (attempt < 3)
{
await Task.Delay(delay);
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2);
}
}
throw new HttpRequestException("Request failed after retries.");
This reduces the speed at which failed clients return to the server.
However, exponential backoff alone does not completely solve retry storms.
Add Jitter So Clients Do Not Retry Together
Suppose 5,000 clients receive an error at the same time.
If every client uses the same backoff:
Wait 200 ms
Wait 400 ms
Wait 800 ms
many clients may retry at nearly the same moments.
That creates another traffic spike.
Jitter introduces a small random variation to the delay.
For example:
Client A → retry after 187 ms
Client B → retry after 243 ms
Client C → retry after 216 ms
Client D → retry after 279 ms
Instead of producing one large synchronized retry wave, requests are spread across time.
A simple approach is:
var random = Random.Shared;
var baseDelay = TimeSpan.FromMilliseconds(500);
var jitter = TimeSpan.FromMilliseconds(random.Next(0, 250));
await Task.Delay(baseDelay + jitter);
Production retry libraries generally provide more robust backoff and jitter strategies, but the principle is important: clients should not all retry at exactly the same time.
Do Not Retry Every HTTP Error
Another common mistake is treating every failure as temporary.
For example:
if (!response.IsSuccessStatusCode)
{
// Retry
}
This is usually too broad.
A retry is more appropriate for failures that may recover without changing the request.
Examples can include:
On the other hand, retrying a request because the client sent invalid data does not normally help.
For example:
400 Bad Request
401 Unauthorized
403 Forbidden
404 Not Found
Repeating the same request without changing anything is unlikely to produce a different result.
A retry policy should therefore classify failures rather than simply retrying everything that is not successful.
Respect HTTP 429 and Retry-After
APIs may explicitly tell clients to slow down.
A 429 response means the client has been rate limited.
The server may also provide a Retry-After header indicating when the client should attempt the request again.
For example:
HTTP/1.1 429 Too Many Requests
Retry-After: 10
A well-behaved client should consider that information instead of immediately retrying.
Conceptually:
API responds 429
↓
Client reads Retry-After
↓
Client waits
↓
Client retries
Ignoring rate-limit signals can turn a controlled rate-limiting mechanism into continuous request pressure.
Timeouts and Retries Can Multiply Each Other
Retries become particularly dangerous when request timeouts are long.
Suppose a client has:
Request timeout: 30 seconds
Retries: 3
A single logical operation could potentially keep a connection or task active for a significant amount of time.
Now imagine thousands of concurrent requests.
The application may accumulate many in-flight operations:
Request 1 → waiting
Request 2 → waiting
Request 3 → waiting
...
Request 10,000 → waiting
When each request eventually retries, the number of active operations can become large.
Timeouts and retries should therefore be designed together.
A retry policy that looks reasonable in isolation may create excessive concurrency when combined with a long timeout.
Retry Only Operations That Are Safe to Repeat
Retrying a GET request is usually easier to reason about than retrying an operation that changes state.
Consider:
POST /payments
If the client sends the request and does not receive a response because of a network timeout, the client does not necessarily know whether the server processed the payment.
Retrying the POST could create a duplicate operation.
For example:
Client
↓
POST /payment
↓
Server processes payment
↓
Network timeout
↓
Client sees failure
↓
Client retries
↓
Second payment may be created
This is why retry design must consider idempotency.
Use Idempotency for Critical Operations
An idempotent operation can safely be repeated without causing an unintended additional effect.
For operations such as payments, order creation, or resource provisioning, an idempotency key can help.
For example:
POST /payments
Idempotency-Key: 7f3e9a1b
The server can associate that key with the original operation.
If the same request is received again:
Same idempotency key
↓
Server recognizes previous operation
↓
Return original result
This allows the client to retry without blindly creating another operation.
The exact implementation depends on the API, but the concept is important whenever a retry can repeat a business action.
Retries Can Overload Dependencies Too
The API itself may not be the real bottleneck.
Imagine this architecture:
Client
↓
API
↓
Database
↓
External service
If the external service becomes slow, the API may start timing out.
Clients retry the API.
The API then creates additional calls to the already struggling dependency.
The result can become:
Client retries
↓
API receives more traffic
↓
API calls dependency more often
↓
Dependency becomes slower
↓
API requests timeout
↓
Clients retry again
The retry policy therefore needs to be considered across the entire request path.
Avoid Nested Retry Policies
A particularly difficult problem occurs when multiple layers retry the same operation.
For example:
HTTP client → 3 retries
API service → 3 retries
Database client → 3 retries
External SDK → 3 retries
A single logical operation can generate many physical attempts.
The exact multiplication depends on where failures occur, but the important point is that retries at different layers can amplify each other.
If one operation is retried by several independent components, the total request volume can become much larger than expected.
Retry ownership should therefore be deliberate.
Do not add a retry policy to every layer simply because each layer can experience temporary failures.
Circuit Breakers Can Stop Repeated Calls
A circuit breaker is useful when a dependency is consistently failing.
Instead of continuing to send requests:
Request
↓
Failure
↓
Retry
↓
Failure
↓
Retry
↓
Failure
a circuit breaker can temporarily stop calls to the unhealthy dependency.
Conceptually:
Healthy
↓
Failures increase
↓
Circuit opens
↓
Calls are rejected or short-circuited
↓
Wait period
↓
Test dependency
↓
Resume if healthy
This gives the downstream service time to recover and prevents the caller from continuously generating traffic.
A circuit breaker is not a replacement for retries. The two patterns often work together.
Bulkheads Limit the Damage
Another useful resilience pattern is bulkheading.
The idea is to prevent one failing dependency from consuming all available application resources.
For example, suppose an API calls three external services:
Payment service
Shipping service
Email service
If the payment service becomes slow, unlimited concurrent calls to it could consume all available threads, connections, or tasks.
A bulkhead can limit the number of concurrent operations for that dependency.
This allows other parts of the application to continue operating.
The goal is not simply to make failed requests succeed. It is to prevent one dependency failure from taking down the entire application.
A Practical Retry Policy
A reasonable API client policy often includes several controls together:
Retry only transient failures
↓
Use exponential backoff
↓
Add jitter
↓
Respect Retry-After
↓
Limit attempts
↓
Use timeouts
↓
Protect non-idempotent operations
↓
Use circuit breaking where appropriate
Each control addresses a different failure mode.
For example:
5xx response
↓
Is retryable?
↓
Yes
↓
Check Retry-After
↓
Calculate backoff + jitter
↓
Retry within attempt limit
This is much safer than simply wrapping every API call in a retry loop.
Common Mistakes
Retrying Immediately
Immediate retries can multiply traffic during an outage.
Use controlled backoff instead.
Retrying Every Status Code
A 400 response usually will not become valid because the client sends the same request again.
Classify errors before retrying.
Ignoring Retry-After
If an API provides explicit retry guidance, clients should consider it.
Using Retries Without Idempotency
Repeating a state-changing request can create duplicate operations.
Use idempotency mechanisms for critical operations where appropriate.
Adding Retries at Every Layer
Multiple retry policies can multiply request volume.
Define where retries belong.
Using Long Timeouts With Many Retries
This can create large numbers of long-lived requests.
Timeout and retry settings should be designed together.
Treating Retries as the Main Reliability Strategy
Retries help with temporary failures. They do not fix overloaded services, persistent configuration problems, or broken dependencies.
Best Practices
Retry only failures that have a reasonable chance of succeeding later.
Use exponential backoff rather than immediate retries.
Add jitter so large groups of clients do not retry simultaneously.
Respect server-provided Retry-After information.
Keep retry counts bounded.
Use short, realistic timeouts.
Design retries around idempotency and business consequences.
Avoid overlapping retry policies across multiple layers.
Use circuit breakers and concurrency limits when dependencies remain unhealthy.
Monitor retry rates separately from normal request rates.
Track the total number of attempts, not just the number of logical requests.
What to Monitor
A service can appear healthy when looking only at successful requests.
For example:
Requests: 10,000
Successful: 9,500
Retries: 4,000
The service may have processed 13,500 physical requests to satisfy 10,000 logical operations.
That additional load matters.
Monitor metrics such as:
Retry count
Retry rate
Attempts per request
HTTP 429 responses
HTTP 5xx responses
Timeout rate
Circuit breaker state
Request latency
Dependency latency
Concurrent requests
A rising retry rate can be an early warning that a dependency is becoming unhealthy.
Summary
Retries can recover temporary failures, but poorly designed retry logic can amplify an outage.
Use bounded retries, exponential backoff, jitter, proper timeouts, and server retry guidance. Make sure state-changing operations are safe to repeat and avoid stacking independent retry policies across every layer.
The goal of retry logic is not to keep sending requests until something works. It is to give a failing system enough room to recover without making the failure worse.
Join the conversation! Your thoughts help the community grow.