An API call that hangs can hold a worker, connection, or page open long after the caller has stopped waiting. Retrying every failure is not a safe fix: the server may have completed a write even if the response never reached your client, and a burst of retries can add load to a struggling service.
A reliable HTTP client needs three things working together: bounded timeouts, a small retry policy for failures that may recover, and a rule for when repeating the operation is safe. This guide walks through those decisions for an individual developer or small team maintaining an API integration.
Set a deadline for the whole operation
“Timeout” can mean several different limits:
| Limit | What it bounds | Why it matters |
|---|---|---|
| Connection timeout | Establishing a new network connection, and often the TLS handshake through a separate setting | Avoids waiting too long before the request can be sent |
| Response timeout | Waiting for response headers, or for data between reads, depending on the client | Detects a server that accepts a request but does not respond promptly |
| Total timeout | The complete operation, including connection setup, redirects, reading the response, and any retry delays | Keeps the caller within its latency budget |
These settings are not interchangeable. A read timeout that resets whenever bytes arrive may still allow a slow response to continue indefinitely. A response-header timeout may not cover reading a large body. Check what your HTTP library actually includes in each timeout.
For example, Go's standard HTTP client can use separate connection, TLS-handshake, response-header, and whole-operation limits:
transport := &http.Transport{
DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext,
TLSHandshakeTimeout: 3 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
ForceAttemptHTTP2: true,
}
client := &http.Client{
Transport: transport,
Timeout: 8 * time.Second,
}
Add the net, net/http, and time imports. These numbers are examples, not universal defaults. In Go, ResponseHeaderTimeout does not limit reading the response body; Client.Timeout caps one complete HTTP request, including that body. It does not by itself cap a series of retries.
Create one operation deadline before the retry loop and pass it to each new request and cancellation-aware wait. For example, if parent is the context from the caller:
operationCtx, cancel := context.WithTimeout(parent, 12*time.Second)
defer cancel()
Add the context import. This shared deadline stops the entire operation even if each attempt has its own client timeout. A custom transport also makes protocol settings worth checking for your Go version and deployment.
Choose limits from the caller's latency budget and observed downstream latency, not from a copied snippet. If a user-facing action can wait only two seconds, a twelve-second HTTP timeout is not a useful budget. Leave enough time for the rest of the application to handle a timeout and return a clear result.
Decide whether repeating the request is safe
Before adding retries, check the operation's semantics. The HTTP specification's idempotency guidance defines an idempotent method as one where multiple identical requests have the same intended effect as one request. GET, HEAD, PUT, and DELETE are defined as idempotent methods, but your API's actual behavior and request body still matter.
For a read-only request, retrying after a connection reset or temporary service failure is often reasonable. A timeout does not prove that the server did nothing, though. It might have processed the request and lost only the response.
Be especially careful with POST requests that create a payment, order, message, or other side effect. Retry one only when the API documents an idempotency mechanism, such as a client-generated idempotency key that the server stores and uses to recognize duplicates. Reuse the same key for every attempt of the same logical operation. Do not assume that adding an arbitrary header makes a server idempotent.
Also make sure the request can actually be replayed. A body streamed from a one-time source may not be available for a second attempt. Buffer small bodies when appropriate, or use a client API that can recreate the request body safely.
Retry selected failures, not everything
Use an explicit policy rather than retrying every non-success response:
- Consider a small allowlist of statuses that may represent a temporary failure, such as
408,429,502,503, or504. Confirm how the API and any gateways in front of it behave. - A
429 Too Many Requestsresponse means the client is sending requests too frequently. The server may includeRetry-After; the status-code specification describes this response. - Avoid repeating unchanged client errors such as
400,403, or422. They usually require a corrected request or permission, not another attempt. Handle authentication refresh as a separate, bounded flow if your API supports it. - Treat network errors and timeouts as uncertain outcomes. Retry them only when repeating the operation is safe.
Do not treat every 5xx as transient by default. A server may return a persistent application error that will not improve if you immediately ask again. Keep the retryable statuses configurable for the service you call.
Use capped exponential backoff with jitter
Immediate retries can make a failing service busier. Instead, increase the wait between attempts and add randomness so many clients do not retry at the same instant.
One simple full-jitter policy is:
delay = random(0, min(maxDelay, baseDelay * 2^(retryNumber - 1)))
For example, with a 250 ms base and a 5 second cap, retry delays can be up to 250 ms, then 500 ms, then 1 second. A policy with three total attempts uses only the first two waits. Keep every delay inside the operation's overall deadline. The AWS Builders' Library guide to timeouts, retries, and jitter explains why bounded retries and jitter help avoid retry traffic making an outage worse.
HTTP's Retry-After field can contain either a delay in seconds or an HTTP date. When a valid value is present on a response you are retrying, wait at least as long as it requests. If that wait does not fit within your remaining deadline, stop and return the response to the caller; retrying earlier defeats the server's signal.
Put the rules together in this order:
- Start one deadline for the complete operation and set a small maximum number of attempts.
- Send a fresh request under that deadline. Return its response if it succeeded or its failure is not eligible for retry.
- Before retrying, confirm that repeating the operation is safe and that an attempt remains. Close the prior response body.
- Choose the server's
Retry-Afterdelay or a jittered backoff delay. Stop if the delay would exceed the remaining deadline. - Wait with a cancellation-aware timer, then try again using the same idempotency key for the same logical write.
Keep retries in one layer of your application where possible. If an SDK, a service wrapper, and a job runner each make three attempts, one user action can fan out into many calls. A retry budget or circuit breaker can help protect a small service during sustained failure, but neither replaces a total deadline or a safe-to-repeat operation.
Make cancellation and failure visible
Pass the caller's cancellation signal or deadline through the request and the backoff wait. If a user closes a page or a job is canceled, do not keep sleeping before another attempt. Stop when the deadline expires, the operation is canceled, the status is not retryable, or the attempt limit is reached.
After retries are exhausted, return a useful error rather than leaving the caller to wait. A user interface can explain that a service is temporarily unavailable and offer a deliberate retry. A background job can record the failure and use its own scheduled retry policy instead of holding a worker open.
For troubleshooting, record the operation name, attempt count, elapsed time, final status or timeout phase, and a request or trace ID when available. Avoid logging authorization headers, secrets, or full request bodies. These details help distinguish a slow connection from a server response that arrived too late.
If you are building the server side too, our guide to rate limiting an API for a small service covers the other half of handling request bursts. For a repeatable way to exercise error responses and document expected behavior, see API testing and documentation workflows for small teams.
A practical checklist
Before shipping an API client, confirm that:
- A total deadline covers connection setup, response reading, and retry delays.
- Connection and response-phase limits match what your client library actually controls.
- Only explicitly selected failures are retried, with a small attempt limit and jitter.
- The client honors
Retry-Afterwhen it can still meet its deadline. - Repeated writes are protected by documented server-side idempotency.
- Cancellation stops both the active request and any pending retry delay.
- Logs and user-facing errors make the final failure understandable without exposing sensitive data.
Timeouts and retries cannot guarantee that a remote service will respond or that every operation is safe to repeat. They give your client a bounded way to recover from temporary failures while preserving a clear point at which it stops and reports the problem.
Official sources
- RFC 9110: HTTP Semantics, idempotent methods
- RFC 9110: HTTP Semantics,
Retry-After - RFC 6585:
429 Too Many Requests - Go
net.Dialerdocumentation - Go
http.Transportdocumentation - Go
http.Clientdocumentation - Go
context.WithTimeoutdocumentation - AWS Builders' Library: Timeouts, retries, and backoff with jitter