n4nAI

API integration

Guide

Error handling and exit codes in LLM shell scripts

Practical guide to robust bash exit codes error handling llm patterns: strict mode, HTTP status separation, custom exit codes, retries, and fault testing.

n4n Team2 min read543 words

Audio narration

Coming soon — every post will get a voice note here.

Most shell scripts that call LLMs fail silently or loop forever because they ignore bash exit codes error handling llm patterns. A robust pipeline needs explicit status checks, timeouts, and fallback logic from the first curl call to the final jq parse.

Set strict mode before any LLM call

Use set -euo pipefail at the top of every script. This forces the script to abort on undefined variables, command errors, and pipeline failures.

#!/usr/bin/env bash
set -euo pipefail

Without pipefail, a failing curl | jq returns the exit code of jq, which may be 0 even if curl timed out. That masks the real error.

Why not just set -e?

-e alone does not catch failures in pipes. If you write curl "$URL" | jq '.choices[0].text', and curl gets a 503, jq may still exit 0 on empty input, giving you an empty string and a false success.

Separate HTTP status from command exit code

curl exit code 0 means it spoke TCP, not that the API succeeded. Capture the HTTP status explicitly.

response=$(curl -sS -w '\n%{http_code}' "$URL" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o","prompt":"echo"}')
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')

Now branch on $http_code:

if [[ "$http_code" != "200" ]]; then
  echo "API error: $http_code" >&2
  exit 3
fi

This is core bash exit codes error handling llm work: the network layer and the model layer are different failure domains.

Define custom exit codes for LLM failures

Don’t let every failure be exit 1. Use a small enum so calling scripts can react.

readonly EX_OK=0
readonly EX_GENERIC=1
readonly EX_BAD_INPUT=2
readonly EX_RATE_LIMIT=3
readonly EX_TIMEOUT=4
readonly EX_SCHEMA=5

case "$http_code" in
  400) exit $EX_BAD_INPUT ;;
  429) exit $EX_RATE_LIMIT ;;
  200) : ;;
  *) exit $EX_GENERIC ;;
esac

If the JSON body lacks expected fields, exit $EX_SCHEMA. This makes upstream orchestration trivial.

Retry with backoff and respect limits

A naive for i in 1 2 3 ignores Retry-After. Parse it.

call_llm() {
  local attempt=0
  local max_attempts=5
  while (( attempt < max_attempts )); do
    local out status
    out=$(curl -sS -w '\n%{http_code}' --max-time 30 "$URL" -d "$1")
    status=$(echo "$out" | tail -n1)
    if [[ "$status" == "200" ]]; then
      echo "${out%$'\n'*}"
      return 0
    fi
    if [[ "$status" == "429" ]]; then
      local retry_after
      retry_after=$(echo "$out" | grep -i 'retry-after' | head -1 | tr -d '\r' | cut -d' ' -f2)
      sleep "${retry_after:-2}"
      ((attempt++))
      continue
    fi
    return $EX_GENERIC
  done
  return $EX_RATE_LIMIT
}

Tradeoff: retries hide transient errors from logs. Log each attempt to stderr.

Use gateway fallback without masking errors

If you route through n4n.ai, its OpenAI-compatible endpoint covers 240+ models and applies automatic fallback when a provider is rate-limited or degraded. A 200 response may contain a different model field than you requested. Check it:

model_returned=$(echo "$body" | jq -r '.model')
if [[ "$model_returned" != "$REQUESTED_MODEL" ]]; then
  echo "warn: fell back to $model_returned" >&2
fi

This preserves bash exit codes error handling llm semantics: success is still 0, but you gain observability.

Stream output without losing exit status

Streaming with curl | jq -c '.choices[]' breaks set -o pipefail if the connection drops mid-stream. Write to a temp file, then parse.

tmp=$(mktemp)
if ! curl -sS "$URL" -d "$payload" > "$tmp"; then
  rm -f "$tmp"
  exit $EX_GENERIC
fi
jq -c '.choices[]' "$tmp" || exit $EX_SCHEMA
rm -f "$tmp"

If you must pipe, use pipefail and check ${PIPESTATUS[0]}.

Fault-inject to validate your script

You cannot trust error handling you haven’t triggered. Mock the endpoint with a local server.

# Using python http.server to return 500
python3 -c "
from http.server import BaseHTTPRequestHandler, HTTPServer
class H(BaseHTTPRequestHandler):
    def do_POST(self):
        self.send_response(500)
        self.end_headers()
HTTPServer(('127.0.0.1', 8080), H).serve_forever()
" &
MOCK_PID=$!
URL="http://127.0.0.1:8080" ./your_script.sh
kill $MOCK_PID

Assert the script exits with the code you mapped to 500. This closes the loop on bash exit codes error handling llm design.

Common pitfalls and tradeoffs

SIGPIPE from head. If you pipe LLM output into head -1, the producer gets SIGPIPE and may exit 141. Use jq '.[0]' instead of streaming into head.

Empty completions. A 200 with choices: [] is valid API but useless. Check length before exit 0.

if [[ $(echo "$body" | jq '.choices | length') -eq 0 ]]; then
  exit $EX_SCHEMA
fi

JSON parsing errors. jq exits 5 on bad JSON. Let that propagate; don’t trap it into 0.

Timeout vs curl failure. --max-time makes curl exit 28. Map that to EX_TIMEOUT explicitly.

Cache-control headers. Gateways that forward provider cache-control hints may return 304 if you send If-None-Match. Handle that as success with cached body, not as error.

Ordered path to ship

  1. set -euo pipefail
  2. Capture HTTP status separate from body.
  3. Map status and schema to custom exit codes.
  4. Retry 429/5xx with backoff, log attempts.
  5. Verify returned model when using fallback gateway.
  6. Test with fault injection before deploying.

Following this path makes your LLM shell scripts behave like real services instead of hopeful scripts. The bash exit codes error handling llm investment is paid back the first time a provider blinks.

Tagsbasherror-handlingshell-scriptingexit-codes

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →