curl Retry Strategies for Unstable APIs
AI generated
$_
#!/
Bash · curl · APIs · Error Handling
curl Retry Strategies for Unstable APIs
When curl --retry is enough and when you need your own backoff instead

Unstable APIs fail in different ways: a short timeout, a 503, or sometimes no response at all. curl ships with built-in retry behavior through --retry, --retry-delay and --retry-connrefused that covers many of these cases, but understanding idempotency is what actually decides whether a retry is safe at all, without processing data twice.

16 min read curl --retry · Backoff Bash · REST APIs · Error Handling

1. Distinguishing transient failures from permanent ones

Not every failed API call deserves the same treatment. A transient failure, for example a brief timeout, a 502 from a load balancer during a deployment on the provider's side, or a 503 due to temporary overload, often resolves itself if the call is retried after a short pause. A permanent failure, for example a 401 because of an invalid token or a 404 because the resource simply does not exist, does not change through retrying and only wastes time if a script tries again anyway.

A robust Bash script talking to an unstable API has to make this distinction explicitly, instead of blanket retrying or blanket giving up on every failure. curl already offers built-in mechanisms that cover the most common transient cases, but it cannot automatically classify every situation correctly.

2. curl --retry: which failures are retried by default

The --retry <count> option tells curl to retry a failed call up to the given number of times. By default only failures curl itself classifies as transient are retried: DNS resolution errors, timeouts, certain connection problems, and HTTP status codes that suggest temporary server overload, such as 408, 429, 500, 502, 503 and 504.

A plain HTTP error status like 404 or 401, on the other hand, does not trigger a retry even with --retry set, because curl assumes another attempt would not change that outcome. This built-in distinction already covers a large share of cases sensibly, without a Bash script having to evaluate HTTP status codes itself.


#!/usr/bin/env bash
set -euo pipefail

# Retries up to 5 times, but only for statuses curl considers transient
curl --retry 5 \
     --retry-delay 2 \
     --fail \
     --silent \
     --show-error \
     "https://api.example.com/v1/orders/42"

3. --retry-delay and curl's built-in exponential backoff

Without an explicit setting, curl does not wait the same amount of time between retries, it automatically increases the wait time from attempt to attempt, a simple exponential backoff that starts at one second by default and doubles with every further failure, capped by an internal upper bound. That prevents a script from putting even more pressure on an already overloaded API through tightly spaced retry attempts.

--retry-delay <seconds> overrides this behavior and switches to a fixed wait time between every attempt. That makes sense when the target application needs a known, fixed recovery window, for example because a server side health check interval is precisely known, but in the general case curl's automatic exponential behavior is the better default, because it adapts on its own to how often failures occur.


#!/usr/bin/env bash
set -euo pipefail

# Fixed 3s delay between every retry, overriding curl's own exponential backoff
curl --retry 4 \
     --retry-delay 3 \
     --retry-max-time 60 \
     --fail \
     "https://api.example.com/v1/status"

4. --retry-connrefused: why connection refused is not retried automatically

A Connection Refused occurs when a host is reachable but no process is listening on the requested port, for example because the target service is restarting or has crashed. curl treats this error as final by default and does not retry it automatically even with --retry set, because a refused connection is genuinely permanent in many classic scenarios, for example a misconfigured port.

In modern cloud and container environments, though, a brief connection refused during a rolling deployment or a container restart is a completely normal, transient state. The --retry-connrefused option explicitly tells curl to include this failure in its retry logic too, which is nearly always the right setting for deployment and monitoring scripts running against dynamic infrastructure.


#!/usr/bin/env bash
set -euo pipefail

# Treat "connection refused" as retryable too -- common during rolling
# deployments when the target container briefly has no listener
curl --retry 6 \
     --retry-delay 5 \
     --retry-connrefused \
     --fail \
     "https://api.example.com/v1/health"

5. When a custom exponential backoff in your own script makes more sense

curl's built-in retry hits its limit as soon as the decision to retry depends on something other than the HTTP status code, for example the content of the response body, a Retry-After header the server sends with a precise wait time, or business logic that needs to run other actions between retries, such as refreshing an expired token. In these cases a custom backoff inside the Bash script is the better choice, because it allows full control over the decision logic between attempts.

A hand-rolled backoff reads the HTTP status code and relevant headers separately, waits for the exact time given in the Retry-After header on a 429 instead of a flat delay, and can attempt to refresh a token on a 401 before retrying the actual call, none of which curl's built-in retry can express.


#!/usr/bin/env bash
set -euo pipefail

url="https://api.example.com/v1/orders"
max_attempts=5
attempt=1

while (( attempt <= max_attempts )); do
  response="$(curl --silent --write-out '\n%{http_code}' "$url")"
  status="${response##*$'\n'}"
  body="${response%$'\n'*}"

  if [[ "$status" == "200" ]]; then
    echo "$body"
    exit 0
  elif [[ "$status" == "429" ]]; then
    retry_after="$(curl --silent -I "$url" | grep -i '^retry-after:' | tr -d '\r' | cut -d' ' -f2)"
    echo "Rate limited, honoring Retry-After: ${retry_after:-5}s" >&2
    sleep "${retry_after:-5}"
  else
    delay=$(( 2 ** attempt ))
    echo "Attempt $attempt failed with $status, backing off ${delay}s" >&2
    sleep "$delay"
  fi
  (( attempt++ ))
done

echo "All $max_attempts attempts failed" >&2
exit 1

6. Idempotency as a prerequisite for a safe retry

A retry is only safe when it is clear the original call actually failed before the server processed it. With a timeout that is unclear: the request could have already fully executed on the server, with only the response never reaching the client. Resending a GET is unproblematic, because it has no side effects by definition, but simply retrying a POST that creates a new order after a timeout can result in the same order being created twice.

For operations that are not naturally idempotent, like POST, an Idempotency-Key is therefore the essential pattern: the script generates a unique key before the first attempt and sends it identically with every retry. An API server that supports this pattern recognizes, based on the key, that this is the same logical operation, executes it only once, and simply returns the original result on every further attempt.


#!/usr/bin/env bash
set -euo pipefail

# Generate one idempotency key BEFORE the first attempt and reuse it
# for every retry -- the server can then safely dedupe on this key
idempotency_key="$(uuidgen)"

curl --retry 4 \
     --retry-delay 2 \
     --retry-connrefused \
     --fail \
     -H "Idempotency-Key: $idempotency_key" \
     -H "Content-Type: application/json" \
     -d '{"product_id": "sku-123", "quantity": 2}' \
     "https://api.example.com/v1/orders"

7. Jitter: why pure exponential backoff becomes a problem with many clients

A purely exponential backoff without any randomness works reliably for a single script, but becomes problematic once many instances of the same script, for example parallel cron jobs or several worker containers, wait according to the same deterministic schedule. If an API briefly goes down, all waiting clients wake up at exactly the same second and send their retries in a burst, immediately overloading the API that just became reachable again, an effect known as a thundering herd.

Random jitter, a small, random deviation from the calculated wait time, spreads out many clients' retries over time instead of bunching them together. In custom backoff implementations that is easy to add by adding a small random amount to the calculated wait time before calling sleep.

8. Combining timeouts with retry instead of treating them separately

--retry alone does not protect against a single attempt itself hanging indefinitely, for example because a connection is established but no response ever follows. That is why --connect-timeout for the maximum time until a connection is established and --max-time for the maximum total time of a single attempt always belong together with --retry in a production ready script, otherwise a single hanging attempt can consume the entire retry budget without a second attempt ever taking place.

In addition, --retry-max-time caps the total time across all retry attempts, regardless of count. That matters especially in CI pipelines, where a single API call should never be allowed to consume an entire pipeline stage's time budget, no matter how many retries are configured.

9. curl retry, custom backoff or circuit breaker: choosing the right tool

For most simple API calls against known, occasionally unstable endpoints, curl's built-in retry behavior is entirely sufficient. Once header based decisions, idempotency handling, or reliably detecting a completely down backend are needed, it is worth investing in custom backoff logic or even a circuit breaker pattern, which stops attempting further calls after repeated failures until a defined recovery period has passed.

Approach Controllability Idempotency handling Typical use
curl --retry Low, fixed HTTP status codes No, without extra logic Simple calls against known, occasionally unstable APIs
--retry-connrefused Low, one additional failure case No Deployment and health check scripts against dynamic infrastructure
Custom backoff in the script High, full control over headers and body Yes, achievable with an idempotency key Rate limit handling, token refresh, POST calls
Circuit breaker High, with state across multiple calls Yes, combined with an idempotency key Persistently unstable backends, high call rates

Mironsoft

Shell automation, DevOps tooling and deployment infrastructure

Shell scripts that hold up in production?

We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.

Code Review

ShellCheck analysis and manual review for critical Bash pattern violations.

Refactoring

Retrofitting error handling, logging and safe file operations.

CI Integration

Wiring ShellCheck and BATS into pipelines and building regression tests.

10. Summary

curl Retry in Bash: The Essentials at a Glance

Core idea

curl --retry automatically retries transient failures like timeouts and 502/503, but not 404 or 401.

Backoff

curl backs off exponentially by default, --retry-delay forces a fixed wait, --retry-connrefused covers container restarts.

Idempotency

An idempotency key makes even POST calls safely retryable without creating duplicate records.

Custom backoff

Needed for Retry-After headers, token refresh, or jitter against thundering herd with many parallel clients.

11. FAQ: curl Retry in Bash: The Essentials at a Glance

1What exactly does curl --retry do?
It retries a failed call up to the given count, but only for failures curl classifies as transient, such as timeouts or HTTP status codes like 502 and 503.
2Does curl retry on a 404?
No. HTTP status codes like 404 or 401 are considered final and do not trigger a retry even with --retry set.
3What does --retry-delay do?
It overrides curl's automatic exponential backoff with a fixed wait time between every retry attempt.
4Why is connection refused not retried automatically?
curl classifies it as a permanent failure by default. --retry-connrefused explicitly changes that, which is sensible during rolling deployments.
5When do I need a custom backoff instead of curl --retry?
When the decision depends on response headers like Retry-After, when a token needs refreshing between attempts, or when jitter is needed against thundering herd.
6What is an idempotency key and why do I need one?
A unique key generated before the first attempt and sent identically with every retry. It lets the server detect duplicate execution of a naturally non-idempotent call like POST.
7Is a GET call always safe to retry?
Generally yes, because GET by definition should have no side effects. Against APIs that do not honor this convention, that is not automatically true.
8What is thundering herd in the context of retries?
When many clients wait according to the same deterministic backoff schedule, they wake up simultaneously and overload an API that just became reachable again. Jitter prevents that.
9How do I sensibly combine timeouts with retry?
--connect-timeout and --max-time cap a single attempt, --retry-max-time caps all attempts combined, so a hanging attempt cannot consume the entire retry budget.
10When is a circuit breaker worth it over simple retry?
When a backend is persistently down and repeated attempts only generate unnecessary load. A circuit breaker stops further attempts for a defined period instead of retrying every call individually.