Skip to content
All writing

Writing

set -e killed the error handler I wrote

Twice in one day, a CI step failed on a command whose failure I had explicitly handled. The handler was correct. It just never ran.

4 min read · ci-cd · bash · github-actions

Every shell step in this project's GitHub Actions workflows starts the same way, because that is the advice everyone gives and it is good advice:

set -euo pipefail

Fail on error, fail on an unset variable, fail on a broken pipe. It turns the default shell behaviour — sail past a failed command and keep going with garbage — into something you can reason about.

Then twice in one day it failed builds that had already succeeded, on lines where I had written the fallback myself.

The first one

The deploy step captures the URL that vercel deploy prints. The CLI emits JSON on a non-TTY, so parsing it with jq is right — but I had seen the format vary, so I wrote a fallback:

vercel deploy --prebuilt --prod --skip-domain > deploy.json
 
url=$(jq -r '.deployment.url // empty' deploy.json)
 
if [ -z "$url" ]; then
  url=$(grep -oE 'https://[A-Za-z0-9._-]+\.vercel\.app' deploy.json | tail -1)
fi

Locally, flawless. On the runner, the step failed — on a deployment that had completed successfully, whose URL was sitting right there in deploy.json.

Under CI=true the CLI prints a bare URL, not JSON. jq was handed a plain string, could not parse it, and exited non-zero. Under set -e that ends the step immediately. The if on the next line was never evaluated. I had written the recovery for exactly this case and the shell exited before reaching it.

The second one

Different workflow, same afternoon. After promoting a deployment, verify the site is serving:

code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 "$SITE_URL/")
 
if [ "$code" != "200" ]; then
  echo "::warning::site returned $code"
fi

The custom domain still pointed at the registrar's parking IP, which accepts the TCP connection and then never answers. curl burned its timeout and exited 28. Non-zero. Step over. The warning I wrote for precisely this situation never printed.

Two different commands, two different failure modes, one bug: set -e fires before your handler does.

Why it is easy to miss

set -e has a well-known list of exemptions. A command is not fatal if it is the condition of an if, part of a && or || chain, negated with !, or in a while test. Every one of those exemptions is about a command whose result you are testing.

if jq -e . deploy.json >/dev/null 2>&1; then ...   # exempt
url=$(jq -r '.url' deploy.json)                    # NOT exempt

The second line looks like it is testing something too — that is what the fallback below it is for. But the exemption is syntactic, not semantic. The shell has no idea you intended to inspect $url afterwards. Assignment from a command substitution is a plain command, and a plain command that fails ends the script.

So the rule that actually predicts behaviour is narrower than "handled failures are fine":

set -e exempts a command whose exit status is consumed by the shell's own syntax. Consuming it yourself, one line later, is too late.

Both fixes

For jq, test first and only then parse — the if condition is an exempt position, so a parse failure becomes a branch instead of an exit:

url=""
if jq -e . deploy.json >/dev/null 2>&1; then
  url=$(jq -r '.deployment.url // empty' deploy.json)
fi
if [ -z "$url" ]; then
  url=$(grep -oE 'https://[A-Za-z0-9._-]+\.vercel\.app' deploy.json | tail -1)
fi

For curl, the exit code is the information, so capture it deliberately rather than letting the shell act on it:

probe() {
  local url="$1" code rc
  set +e
  code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "$url")
  rc=$?
  set -e
  if [ "$rc" -ne 0 ]; then echo "unreachable(curl:$rc)"; else echo "$code"; fi
}
 
alias_code=$(probe "https://sadiqeen.vercel.app/")

Turning set -e off for three lines is the honest version of what the code already meant. curl || true would have worked too, but it discards the distinction between "returned 503" and "never connected," which on that step is the whole diagnosis — the parking-IP hang and a bad deploy look identical without it.

What I took from it

The failure mode was not that error handling was missing. It was that error handling was present, looked right in review, and was unreachable — which is strictly worse, because it reads as covered.

set -e is still the right default. I am not arguing for turning it off. But it makes a specific and narrow promise, and I had been reading it as a much broader one for years without noticing, because the two readings agree on every script that never handles a failure.

The tell, in hindsight, is anywhere the shell captures output from something that legitimately fails. Every one of those is a place where the recovery path below it may be decorative, and neither of these bugs was reachable from my laptop — one needed CI=true, the other needed DNS pointing somewhere real and wrong. Both were found by running the thing, in the place it runs.