|
| 1 | +--- |
| 2 | +title: "Scripting REST APIs Without Fragile curl Loops" |
| 3 | +linkTitle: "Scripting REST APIs Without Fragile curl Loops" |
| 4 | +date: 2026-06-30 |
| 5 | +author: "Daniel Taylor" |
| 6 | +description: API shell scripts fail in predictable ways — silent HTTP errors, hand-rolled pagination, no retries, unparseable output. Restish bakes the boring reliability into the CLI with stdout and stderr discipline, HTTP-aware exit codes, bounded pagination, and retry and timeout flags. |
| 7 | +canonical_url: "https://rest.sh/blog/scripting-rest-apis-without-fragile-curl-loops/" |
| 8 | +categories: |
| 9 | + - Automation |
| 10 | +tags: |
| 11 | + - automation |
| 12 | + - cli |
| 13 | + - api |
| 14 | + - devops |
| 15 | +--- |
| 16 | + |
| 17 | +Somewhere in your infrastructure there is a shell script with a loop like |
| 18 | +this: |
| 19 | + |
| 20 | +```bash |
| 21 | +url="https://api.example.com/items?page=1" |
| 22 | +while [ -n "$url" ]; do |
| 23 | + resp=$(curl -s "$url") |
| 24 | + echo "$resp" | jq -r '.items[].id' |
| 25 | + url=$(echo "$resp" | jq -r '.links.next // empty') |
| 26 | +done |
| 27 | +``` |
| 28 | + |
| 29 | +It works, mostly. It has also quietly accumulated a list of failure modes that |
| 30 | +nobody will notice until a bad day: |
| 31 | + |
| 32 | +- A `500` response exits with status `0`, so the error page flows into `jq`, |
| 33 | + which prints nothing, and the script "succeeds" with empty output. |
| 34 | +- There is no retry, so one transient network blip fails the whole job — or |
| 35 | + worse, half of it. |
| 36 | +- There is no timeout, so a hung connection hangs the cron job behind it. |
| 37 | +- There is no page bound, so a pagination bug upstream turns the loop into an |
| 38 | + accidental load test. |
| 39 | +- If `next` ever points somewhere unexpected — another host, an attacker- |
| 40 | + influenced URL in a response body — the loop follows it without a thought. |
| 41 | + |
| 42 | +None of these are exotic. They are the standard tax on hand-rolling HTTP |
| 43 | +plumbing in shell, paid one incident at a time. This post is about paying it |
| 44 | +once, in the tool, instead. |
| 45 | + |
| 46 | +Restish is a CLI for REST-ish HTTP APIs, and one of its design goals is being |
| 47 | +boring in scripts: response data on stdout, diagnostics on stderr, exit codes |
| 48 | +that mean something, and bounded loops by default. Here is what that looks |
| 49 | +like for each failure mode above. |
| 50 | + |
| 51 | +<div class="restish-blog-callout"> |
| 52 | + <strong>Try it as you read.</strong> Runnable examples below use the browser |
| 53 | + preview against the public <code>api.rest.sh</code> API. Multi-command |
| 54 | + pipelines and failure demos are shown as fenced shell snippets. |
| 55 | +</div> |
| 56 | + |
| 57 | +## The Loop, Replaced |
| 58 | + |
| 59 | +The whole script above is one command: |
| 60 | + |
| 61 | +{{< restish-example >}} |
| 62 | +restish api.rest.sh/images -f body.self -o lines |
| 63 | +{{< /restish-example >}} |
| 64 | + |
| 65 | +Restish recognizes the collection's `next` links and follows them |
| 66 | +automatically, the `-f` filter selects one field from each item, and |
| 67 | +`-o lines` prints one scalar per line for the next program in the pipe. The |
| 68 | +pagination is bounded (25 pages by default, configurable with |
| 69 | +`--rsh-max-pages`), and next-page URLs must stay on the same origin — scheme, |
| 70 | +hostname, and effective port. A link that wanders off-origin stops the loop |
| 71 | +with a warning instead of being followed. |
| 72 | + |
| 73 | +When a script needs the whole logical collection at once — to count it, sort |
| 74 | +it, or deduplicate it — collect first, then filter: |
| 75 | + |
| 76 | +{{< restish-example >}} |
| 77 | +restish api.rest.sh/images --rsh-collect -f '.body | length' |
| 78 | +{{< /restish-example >}} |
| 79 | + |
| 80 | +And when you want to bound the work explicitly, say so: |
| 81 | + |
| 82 | +```bash |
| 83 | +restish api.rest.sh/images --rsh-no-paginate # exactly one page |
| 84 | +restish api.rest.sh/images --rsh-max-pages 3 # at most three pages |
| 85 | +restish api.rest.sh/images --rsh-max-items 100 # at most 100 items |
| 86 | +``` |
| 87 | + |
| 88 | +## Exit Codes That Mean Something |
| 89 | + |
| 90 | +A script's first question about an API call is "did it work?", and the answer |
| 91 | +should not require parsing anything. Restish |
| 92 | +[maps outcomes to exit codes](/docs/guides/automation/): |
| 93 | + |
| 94 | +| Exit code | Meaning | |
| 95 | +| --- | --- | |
| 96 | +| 0 | Success | |
| 97 | +| 1 | Runtime failure (network, TLS, …) | |
| 98 | +| 2 | Usage error (bad arguments) | |
| 99 | +| 3 | Final HTTP `3xx` response | |
| 100 | +| 4 | Final HTTP `4xx` response | |
| 101 | +| 5 | Final HTTP `5xx` response | |
| 102 | +| 130 | Interrupted (SIGINT) | |
| 103 | + |
| 104 | +So ordinary shell control flow just works: |
| 105 | + |
| 106 | +```bash |
| 107 | +if ! restish -S api.rest.sh/status/204; then |
| 108 | + echo "health check failed" >&2 |
| 109 | + exit 1 |
| 110 | +fi |
| 111 | +``` |
| 112 | + |
| 113 | +`-S` suppresses output for the cases where the exit code is the whole answer. |
| 114 | + |
| 115 | +And when the reaction depends on whose fault it was, the `4` versus `5` split |
| 116 | +is already there — no body parsing required: |
| 117 | + |
| 118 | +```bash |
| 119 | +restish -S api.rest.sh/status/204 |
| 120 | +case $? in |
| 121 | + 0) ;; # healthy |
| 122 | + 4) echo "client bug: fix the request" >&2 ;; |
| 123 | + 5) echo "server error: retry later" >&2 ;; |
| 124 | + *) echo "transport or usage failure" >&2 ;; |
| 125 | +esac |
| 126 | +``` |
| 127 | + |
| 128 | +HTTP error statuses still write the response body to stdout before exiting |
| 129 | +non-zero, so you can log what the API actually said. And when the script |
| 130 | +handles HTTP status itself and wants the error body as data — a structured |
| 131 | +problem response, say — keep the body and force a zero exit: |
| 132 | + |
| 133 | +{{< restish-example >}} |
| 134 | +restish api.rest.sh/problem --rsh-ignore-status-code |
| 135 | +{{< /restish-example >}} |
| 136 | + |
| 137 | +## stdout Is for Data, stderr Is for Commentary |
| 138 | + |
| 139 | +Restish keeps the streams disciplined: selected response data goes to stdout; |
| 140 | +progress, warnings, verbose request traces, and pagination notices go to |
| 141 | +stderr. A pipeline never has to strain diagnostics out of its data, and `-v` |
| 142 | +debugging does not corrupt the output a downstream step consumes. |
| 143 | + |
| 144 | +Output formats make the data side explicit instead of terminal-shaped: |
| 145 | + |
| 146 | +```bash |
| 147 | +restish api.rest.sh/images -o json # one complete JSON document |
| 148 | +restish api.rest.sh/images -o ndjson # one JSON record per line |
| 149 | +restish api.rest.sh/images -f body.self -o lines # one scalar per line |
| 150 | +``` |
| 151 | + |
| 152 | +`json` suits a single document handed to one consumer, `ndjson` suits record |
| 153 | +streams processed line by line, and `lines` suits scalar values feeding |
| 154 | +`xargs`, `sort`, or a `while read` loop. There is no guessing about |
| 155 | +prettification either: redirecting an unfiltered response writes the body |
| 156 | +bytes unchanged, and any `-f` filter or explicit `-o` format renders |
| 157 | +structured output for the next program. |
| 158 | + |
| 159 | +## Retries and Timeouts Without a Wrapper |
| 160 | + |
| 161 | +The retry-with-backoff wrapper function pasted between shell scripts can |
| 162 | +retire. Bound the time, state the retries: |
| 163 | + |
| 164 | +```bash |
| 165 | +restish 'api.rest.sh/slow?delay=2s' --rsh-timeout 3s |
| 166 | +restish 'api.rest.sh/flaky?failures=1&key=my-job' --rsh-retry 2 |
| 167 | +``` |
| 168 | + |
| 169 | +One detail matters more than it looks: automatic retries apply to `GET` and |
| 170 | +`HEAD` by default, not to writes. Replaying a `POST` because the first attempt |
| 171 | +timed out is how scripts double-charge customers. When a non-idempotent |
| 172 | +endpoint genuinely tolerates replay, opting in is explicit: |
| 173 | +`--rsh-retry-unsafe`. |
| 174 | + |
| 175 | +## Putting It Together |
| 176 | + |
| 177 | +A realistic CI step — check that every image resource an API lists is |
| 178 | +actually reachable: |
| 179 | + |
| 180 | +```bash |
| 181 | +#!/usr/bin/env bash |
| 182 | +set -euo pipefail |
| 183 | + |
| 184 | +restish api.rest.sh/images -f body.self -o lines --rsh-max-items 50 | |
| 185 | +while read -r path; do |
| 186 | + restish -S "api.rest.sh$path" --rsh-timeout 10s --rsh-retry 2 || |
| 187 | + { echo "unreachable: $path" >&2; exit 1; } |
| 188 | +done |
| 189 | +``` |
| 190 | + |
| 191 | +Every fragile part of the opening loop is now someone else's tested code: |
| 192 | +pagination is automatic and bounded, transient failures retry, hangs time |
| 193 | +out, HTTP errors become exit codes, and stdout carries nothing but data. |
| 194 | + |
| 195 | +This works the same against your own APIs — and if an API publishes OpenAPI, |
| 196 | +you can [connect it](/docs/getting-started/connect-to-an-api/) and write the |
| 197 | +script against generated commands with profiles and auth handled: |
| 198 | + |
| 199 | +```bash |
| 200 | +restish api connect example api.rest.sh |
| 201 | +restish example list-images -f body.self -o lines |
| 202 | +``` |
| 203 | + |
| 204 | +## Try It |
| 205 | + |
| 206 | +Install Restish: |
| 207 | + |
| 208 | +```bash |
| 209 | +brew install restish |
| 210 | +restish --version |
| 211 | +``` |
| 212 | + |
| 213 | +Or with Go: |
| 214 | + |
| 215 | +```bash |
| 216 | +go install github.com/rest-sh/restish/v2/cmd/restish@latest |
| 217 | +restish --help |
| 218 | +``` |
| 219 | + |
| 220 | +Then run the replacement for the opening loop: |
| 221 | + |
| 222 | +```bash |
| 223 | +restish api.rest.sh/images -f body.self -o lines |
| 224 | +``` |
| 225 | + |
| 226 | +Useful next stops: |
| 227 | + |
| 228 | +- [Scripting and Automation](/docs/guides/automation/) is the durable |
| 229 | + reference for exit codes, streams, and the stable script flags. |
| 230 | +- [Pagination and Links](/docs/guides/pagination/) covers limits, collect |
| 231 | + mode, and APIs that paginate without `next` links. |
| 232 | +- [Retries and Caching](/docs/guides/retries-and-caching/) goes deeper on |
| 233 | + retry behavior. |
| 234 | +- [Output](/docs/guides/output/) explains the format model and redirect |
| 235 | + semantics. |
| 236 | + |
| 237 | +The fragile parts of API scripts were never the interesting parts. Move them |
| 238 | +into the tool, and the script that is left is the part you actually meant to |
| 239 | +write. |
0 commit comments