curl Returns Exit Code 0 on HTTP 404: Fix CI Failure Checks
A curl request can receive HTTP 404 and still exit with code 0. By default, curl treats a completed HTTP transfer as success even when the server returns an error response. Use --fail-with-body to retain the response body and return an error, then check how your shell passes that status to CI.
There are two separate failure checks: curl must recognize the HTTP error, and the shell must preserve curl’s nonzero exit status. Adding only set -o pipefail does not solve a request that already exits successfully.
Choose the HTTP failure behavior
The curl manual documents --fail-with-body for HTTP responses with status 400 or greater. It returns error 22 while keeping the response body. The option was added in curl 7.76.0; check curl --version on the runner, rather than assuming your laptop and CI have the same version.
| Request behavior | HTTP 404 exit status | Error response body |
|---|---|---|
| Default curl | 0 if the transfer completes | Retained |
--fail / -f | 22 for this case | Suppressed |
--fail-with-body | 22 for this case | Retained |
Do not combine --fail and --fail-with-body: the options are mutually exclusive. If an older runner cannot use --fail-with-body, --fail provides the HTTP failure check for this case, but loses the server’s diagnostic body. The manual also warns that --fail has authentication-related exceptions; do not treat this 404 demonstration as exhaustive testing of every HTTP exchange.
Reproduce both failure layers locally
The following fixture needs Python 3, curl 7.76.0 or newer, and Bash. It starts an HTTP server on an automatically selected loopback port and always returns a small JSON body with HTTP 404. No public endpoint, credential, or deployment is required.
Run it from a terminal:
python3 - <<'PY'from http.server import BaseHTTPRequestHandler, HTTPServerfrom threading import Threadimport osimport subprocess
class Handler(BaseHTTPRequestHandler): def do_GET(self): body = b'{"error":"missing"}\n' self.send_response(404) self.send_header('Content-Type', 'application/json') self.send_header('Content-Length', str(len(body))) self.end_headers() self.wfile.write(body)
def log_message(self, *args): pass
server = HTTPServer(('127.0.0.1', 0), Handler)Thread(target=server.serve_forever, daemon=True).start()env = dict(os.environ, URL=f'http://127.0.0.1:{server.server_port}/missing')checks = [ ('plain', 'curl -q -sS "$URL"'), ('fail', 'curl -q -sS --fail "$URL"'), ('fail-with-body', 'curl -q -sS --fail-with-body "$URL"'), ('pipeline without pipefail', 'set +o pipefail; curl -q -sS --fail-with-body "$URL" | cat'), ('pipeline with pipefail', 'set -o pipefail; curl -q -sS --fail-with-body "$URL" | cat'), ('pipefail without HTTP failure check', 'set -o pipefail; curl -q -sS "$URL" | cat'),]try: for label, command in checks: result = subprocess.run(['bash', '-c', command], env=env, capture_output=True, text=True) print(f'{label}: exit={result.returncode}, body={result.stdout.strip()!r}')finally: server.shutdown() server.server_close()PYThe fixture was executed during preparation on macOS with curl 8.7.1 and Bash 3.2.57 on October 4, 2026. Its output was:
plain: exit=0, body='{"error":"missing"}'fail: exit=22, body=''fail-with-body: exit=22, body='{"error":"missing"}'pipeline without pipefail: exit=0, body='{"error":"missing"}'pipeline with pipefail: exit=22, body='{"error":"missing"}'pipefail without HTTP failure check: exit=0, body='{"error":"missing"}'The fixture captures stderr separately, so curl’s error messages are absent from this printed output. -q is deliberately the first curl option to skip the default curl configuration file; -sS removes progress output while keeping error messages enabled.
For the fourth check, curl fails but cat successfully reads the error body. Bash normally uses the last command’s status for a pipeline, so the pipeline returns 0. With pipefail, Bash instead returns the rightmost nonzero status, which is curl’s 22 in this example. This is the behavior defined in the Bash pipeline reference.
Preserve the failure in a CI script
For a single request, an explicit conditional avoids depending on implicit shell exit behavior. This Bash pattern saves the response for inspection and exits with curl’s status on failure:
#!/usr/bin/env bashurl='https://api.example.com/health' # Replace with your endpoint.
if curl -q --silent --show-error --fail-with-body \ --connect-timeout 10 --max-time 30 \ --output response.json "$url"; then printf 'HTTP request completed without a curl failure.\n'else rc=$? printf 'curl failed with exit code %s\n' "$rc" >&2 # Inspect response.json only where its contents may be disclosed. exit "$rc"fiThe local 404 fixture also verified the conditional’s failure branch and saved JSON body. The public endpoint above is illustrative and was not called. response.json may contain sensitive error details; writing it to disk does not automatically make it suitable for CI logs or uploaded artifacts. If you intentionally upload that file, account for your workflow’s artifact file selection.
Capture $? immediately in the else branch. Avoid if ! curl ...; then rc=$?: the ! has already inverted the status, so that assignment does not preserve the original curl failure.
If your real command pipes curl into another program, enable pipefail in the Bash process that runs the pipeline and explicitly return its failure:
set -o pipefailif curl -q -sS --fail-with-body "$url" | cat; then printf 'Pipeline completed without a failure.\n'else rc=$? exit "$rc"ficat isolates the shell behavior here; replace it with your actual consumer only after checking that program’s exit behavior. pipefail changes the pipeline’s status, not whether downstream commands run. It does not validate the JSON body or stop a consumer from processing an error response.
Define what counts as success
These examples prove the HTTP 404 boundary, not an application health guarantee. A response can be valid HTTP yet contain an application error. Likewise, the documented HTTP failure threshold does not assert that a redirect or every non-200 status should fail your particular check.
If your contract requires exactly HTTP 200, a specific JSON field, or a final destination after redirects, add and verify that condition separately. For the narrower problem shown here, use the HTTP failure flag first, then verify the standalone command, pipeline, and final script exit status against a deliberately failing endpoint.
References: