Skip to content

Commit 5bdada6

Browse files
Merge pull request #385 from rest-sh/codex/publish-scripting-blog
[codex] Publish scripting APIs blog post
2 parents fc5e247 + 7606e92 commit 5bdada6

1 file changed

Lines changed: 239 additions & 0 deletions

File tree

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
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

Comments
 (0)