Manoj
←Back to Blog
·4 min read

Retries, Timeouts, and Duplicate Requests: Making APIs More Reliable

Make API failures recoverable with bounded timeouts, selective retries, jitter, and concurrency-safe idempotency.

backendapi-designreliabilitydistributed-systems

Retries, Timeouts, and Duplicate Requests: Making APIs More Reliable

A request times out. The client tries again. The second attempt succeeds. That sounds like a recovery story until you discover that the first attempt also completed and the user now has two orders.

Reliability is not simply retrying whenever something looks broken. It is deciding how long to wait, which failures are safe to repeat, and how the server recognizes the same operation arriving again.

A timeout leaves the outcome uncertain

When a client stops waiting, it learns that no acceptable response arrived in time. It does not necessarily learn whether the server committed a change.

That distinction matters for writes. An upstream service could accept a request and lose the response on the way back. Retrying the operation without a stable identity can repeat the side effect.

Start by classifying the operation. Reading a public article is different from charging a payment method. Both need sensible timeouts, but they should not share a retry policy just because they use the same HTTP client.

Put the whole interaction on a budget

Choose an overall deadline based on the caller's needs. Allocate time for connection setup, response handling, and any retries inside that budget.

Do not give each of five attempts the full time the user was willing to wait. That turns a brief failure into a long, frustrating interaction. Also make sure the chosen client timeout covers the phases you intend; connection and response timeouts are not always equivalent.

In Node.js, a bounded fetch can look like this:

const response = await fetch("https://api.example.com/catalog", {
  signal: AbortSignal.timeout(2000),
});

if (!response.ok) {
  throw new Error(`Catalog request failed: ${response.status}`);
}

This limits the client wait. It does not prove the remote server cancelled its work. Propagate cancellation when supported, but keep the server's recovery behavior safe even when cancellation arrives late.

Retry the failures that can improve

A short interruption or an explicitly retryable upstream response may justify another attempt. Invalid input, missing authorization, and a deterministic business-rule rejection usually require a change rather than repetition.

Document the retryable cases for each dependency. Respect server instructions such as Retry-After where applicable, cap attempts, and stop once the overall deadline is exhausted.

Avoid retrying at every layer. A browser, API gateway, application, and SDK can multiply one user action into many downstream requests. Pick a layer that understands the operation and inspect library defaults before adding another loop.

AWS's discussion of timeouts, retries, and jitter describes why retries can amplify overload rather than relieve it.

Spread attempts out

If every client waits exactly one second before retrying, they can return together and create another spike. Backoff with jitter spreads those attempts over time.

One illustrative delay calculation is:

const retryDelayMs = (attempt: number): number => {
  const ceiling = Math.min(2000, 200 * 2 ** attempt);
  return Math.floor(Math.random() * ceiling);
};

This calculates a delay, not a complete retry policy. The caller still needs an attempt limit, a deadline, error classification, and cancellation handling. In particular, a random delay does not make an unsafe write safe to repeat.

Give writes an operation identity

For a create-order endpoint, accept an idempotency key generated once for the user's intended operation. The client reuses it for retries instead of generating a new key each time.

Scope the key to the authenticated account and operation. Store a request fingerprint so that the same key with different input is rejected. Claim the key atomically, record progress, and persist the final result or a reference to it.

Concurrent requests are the important test. Two requests that both read an empty lookup before inserting can still create duplicates unless a unique constraint or equivalent atomic mechanism enforces ownership.

If the side effect happens in another system, use its supported idempotency mechanism or design a reconciliation process. A local database record cannot make an arbitrary external action transactional.

Test uncertainty deliberately

Simulate a response disappearing after the write commits. Send the same key simultaneously. Retry after a worker restarts. Verify that conflicting payloads fail and completed operations return a consistent result.

Log the operation ID, attempt, duration, and outcome so that recovery can be followed across requests. Define how long idempotency records remain valid and tell callers what that window means.

The goal is not to hide every failure. It is to make the next action predictable when a caller cannot tell what happened.