Systems troubleshooting guide11 min read

How to make curl fail safely in shell scripts

A curl process exit code and an HTTP response status answer different questions. Make scripts bound their requests, preserve useful error evidence, and branch on the result they actually need.

Reviewed by the Terminaster editorial team · Updated

What you will take away

  • By default, curl can exit zero after receiving an HTTP 404 or 500 response.
  • Use --fail or --fail-with-body when HTTP 400-and-higher responses should make curl fail.
  • Capture `$?` immediately, and do not treat HTTP code 000 as a server response.
  • Add connect and total timeouts, and retry only operations whose repetition is safe.

Separate curl’s exit code from the HTTP status

The shell exit code reports whether curl completed its transfer according to the selected options. The HTTP status is application-protocol data returned by an HTTP server. A server can successfully return a 404 response, so curl normally exits zero even though the resource was not found.

Capture the exit code immediately after curl. Any intervening command replaces `$?`. An HTTP code displayed as `000` is curl’s placeholder when no HTTP response code was obtained; it is not a real status sent by a server.

Capture both results explicitly

http_code=$(curl -sS \
  --connect-timeout 5 --max-time 30 \
  -o response.json -w '%{http_code}' \
  https://api.example/items)
curl_rc=$?

printf 'curl_rc=%s http_code=%s\n' "$curl_rc" "$http_code"

Choose whether HTTP errors should fail curl

`--fail` normally makes curl return exit code 22 for HTTP responses with status 400 or greater and suppresses the response body. The curl manual warns that this is not completely fail-safe, especially around authentication responses such as 401 and 407. `--fail-with-body` returns 22 under the corresponding detected HTTP failure conditions but retains the body, which is often useful when an API returns structured error details.

Neither option decides which statuses your application may accept. For example, a cache check may treat 404 as an expected miss, while a deployment download should reject it. Encode that policy in the script. Also avoid `if ! curl; then rc=$?`: the logical negation replaces the original curl status.

Fail on HTTP errors while retaining the body

if curl -sS --fail-with-body \
  --connect-timeout 5 --max-time 30 \
  -o response.json \
  https://api.example/items; then
  printf 'request completed\n'
else
  rc=$?
  printf 'request failed (curl exit %s)\n' "$rc" >&2
  exit "$rc"
fi

Publish a response only after both checks pass

For application-specific status rules, download into a private temporary file and capture curl’s transfer result separately from the HTTP status. Move the body into its final pathname only after the transfer succeeded and the status is one your application accepts.

The example treats 404 as an expected cache miss, accepts only 200 as a usable payload, and leaves no partial response at the final pathname. Its trap removes the temporary file on normal exit and common signals.

  • Exit 6 means curl could not resolve the host.
  • Exit 7 means curl failed to connect to the host.
  • Exit 22 is returned for an HTTP error when a fail option is active.
  • Exit 28 means the configured operation timed out.

Handle an expected 404 without publishing partial data

umask 077
tmp=$(mktemp)
cleanup() { rm -f -- "$tmp"; }
trap cleanup EXIT
trap 'exit 1' HUP INT TERM

http_code=$(curl -sS \
  --connect-timeout 5 --max-time 30 \
  --output "$tmp" --write-out '%{http_code}' \
  https://api.example/items/42)
curl_rc=$?

if [ "$curl_rc" -ne 0 ]; then
  printf 'transfer failed (curl exit %s)\n' "$curl_rc" >&2
  exit "$curl_rc"
elif [ "$http_code" = 404 ]; then
  printf 'item not found\n'
  exit 0
elif [ "$http_code" != 200 ]; then
  printf 'unexpected HTTP status %s\n' "$http_code" >&2
  exit 1
fi

mv -- "$tmp" response.json
trap - EXIT HUP INT TERM

Bound every automated request and retry carefully

Automation should not wait forever. Set a connect timeout and a per-attempt total timeout appropriate to the service. Limit the retry count and the window in which curl may start another retry; `--retry-max-time` is not a hard wall-clock cap because an attempt already in progress can finish after that window.

Repetition can duplicate effects. A GET is generally designed to be safe, while a POST may create a second resource unless the API supplies idempotency guarantees. Read the API contract and curl retry behavior before retrying a state-changing request.

A bounded read request with conservative retries

if curl -sS --fail-with-body \
  --connect-timeout 5 --max-time 10 \
  --retry 2 --retry-delay 1 --retry-max-time 25 \
  -o response.json \
  https://api.example/items; then
  jq . response.json
else
  rc=$?
  printf 'request failed (curl exit %s)\n' "$rc" >&2
  exit "$rc"
fi

Common questions

Frequently asked questions

Why does curl exit 0 after an HTTP 500?

Without a fail option, curl treats receiving a valid HTTP response as a successful transfer. Use --fail or --fail-with-body when detected status 400-and-above responses should produce a nonzero exit, and account for the documented authentication edge cases.

Should scripts use --fail or --fail-with-body?

Use --fail when an error body is unnecessary or should not be written. Use --fail-with-body when the API’s error response is useful, while still handling that body as potentially sensitive and untrusted data.

Is HTTP status 000 a network error code?

No. It is the formatted value curl emits when no HTTP response status is available. Use curl’s nonzero process exit code to identify the transfer failure category.

Keep learning

Sources and next steps