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.
Table of Contents
- 1. Distinguishing transient failures from permanent ones
- 2. curl --retry: which failures are retried by default
- 3. --retry-delay and curl's built-in exponential backoff
- 4. --retry-connrefused: why connection refused is not retried automatically
- 5. When a custom exponential backoff in your own script makes more sense
- 6. Idempotency as a prerequisite for a safe retry
- 7. Jitter: why pure exponential backoff becomes a problem with many clients
- 8. Combining timeouts with retry instead of treating them separately
- 9. curl retry, custom backoff or circuit breaker: choosing the right tool
- 10. Summary
- 11. FAQ
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.