I pointed a plain curl at an endpoint that answers every request with a 502, the way an API does in the middle of a bad deploy, and saved the result the way a nightly job would:
Exit 0, and a zero-byte file where the data should be. Nothing downstream can tell. set -euo pipefail has nothing to trip on, the parser reads an empty file, and whatever gets built from it is quietly wrong until someone notices the numbers. Same endpoint, same box, through the wrapper below:
Three tries, about six and a half seconds, and a non-zero exit that cron and the next step can both see.
Why does curl succeed when the request failed?
curl's exit code answers one question: did I get a response back? It says nothing about what that response was. A 200 with your JSON and a 500 with an error page are both, to curl, a successful round trip. So set -euo pipefail won't save you here — the command didn't fail, it just returned something you didn't want. Any script that treats "curl ran" as "the API returned good data" is one bad deploy on the other end away from silently processing garbage.
The wrapper: check status, time out, retry
A 404 gets one try, because retrying a wrong URL only repeats the mistake, and a good response goes to stdout with the status on stderr:
The body goes to stdout so you can pipe it straight into jq; the status commentary goes to stderr so it doesn't pollute the data. Because api_get returns non-zero when the API is truly down, the caller can do data=$(api_get "$url") || exit 1 and mean it.
Why not just use --fail?
curl --fail is the blunt version of this: it makes curl exit 22 on 4xx/5xx so set -e trips. It's genuinely useful for a quick one-liner. The reason I don't reach for it in a real job is that --fail discards the response body on error — so when the API hands you a 400 with {"error":"missing field x"}, you get the exit code but not the message that tells you what's wrong. Capturing the code and body separately costs three extra lines and gives you something to log. And --fail still doesn't distinguish "retry this" (429/503) from "don't bother" (401/404), which is the distinction that actually keeps a flaky endpoint from paging you.
What this still doesn't cover
This handles a single request. It does not walk paginated results, refresh an OAuth token that expired mid-run, or read a Retry-After header to back off politely (it uses a fixed delay). Those are real and worth adding when the API demands them — see the Shell Scripts That Talk to APIs guide for the fuller pattern. But the status-code check and the timeouts are the two things that turn "exit 0 and an empty file" into "fails tonight, loudly."
Once you've got a clean 2xx body, the next job is reading fields out of it without regex — that's parsing JSON with jq. And when a request finally does fail for real, you want to hear about it: send the failure to Slack.