Skip to content

Retry wrapper for transient failures

The CLI already retries HTTP 429, 502, 503, and 504 responses up to 3 times with exponential backoff — see Built-in Resilience. For nearly every workflow that’s enough.

This recipe is for the cases where it isn’t:

  • A long-running batch job that hits the rate-limit envelope and needs more than 3 retries.
  • Network flakes where DNS or TLS itself misbehaves and never reaches the CLI’s retry layer.
  • A dependent system (CI runner, proxy) that occasionally rejects requests.

Each built-in retry announces itself on stderr — ⚠ Rate limited, retrying in 1.0s (attempt 1/3)... — unless you passed --json, which suppresses the chatter so it can’t pollute a structured pipeline. After the third and final failure, the CLI prints Bitbucket’s own message to stderr, prefixed with a red (or ERR under --no-unicode). For a rate limit that is typically:

✗ Rate limit for this resource has been exceeded

The HTTP status number does not appear in text mode. To branch on it, pass --json: the error envelope goes to stderr as a single compact line.

{"name":"APIError","code":2004,"message":"Rate limit for this resource has been exceeded","context":{"status":429},"statusCode":429,"response":{"type":"error","error":{"message":"Rate limit for this resource has been exceeded"}}}

The envelope is flat — there is no error wrapper object — and code is a number from the Error Codes reference, here 2004 (API_RATE_LIMITED). statusCode and response appear on API errors only; a validation or config error has neither, and context on an API error also carries method and url (trimmed above). A hint string is added for 401, 403 and 404 responses; a 429 gets no hint.

If the operation is idempotent, retrying is safe.

#!/bin/bash
# bb-retry.sh - Run a bb command with exponential backoff.
#
# Usage:
# ./bb-retry.sh pr list -w myworkspace -r myrepo --json
#
# Env vars:
# MAX_ATTEMPTS default 5
# INITIAL_DELAY default 2 (seconds)
# MAX_DELAY default 60 (seconds)
set -euo pipefail
MAX_ATTEMPTS="${MAX_ATTEMPTS:-5}"
INITIAL_DELAY="${INITIAL_DELAY:-2}"
MAX_DELAY="${MAX_DELAY:-60}"
attempt=1
delay="$INITIAL_DELAY"
while true; do
# `bb "$@" && exit 0`, not `if bb "$@"; then exit 0; fi` — after a
# non-taken `if` branch, $? is the status of the `if` itself (always 0),
# so the wrapper would report "exit 0" and exit 0 on total failure.
bb "$@" && exit 0
exit_code=$?
if [ "$attempt" -ge "$MAX_ATTEMPTS" ]; then
echo "bb $* failed after $attempt attempts (exit $exit_code)" >&2
exit "$exit_code"
fi
# Add jitter: ±25% of the current delay
jitter=$(( RANDOM % (delay / 2 + 1) - delay / 4 ))
sleep_time=$(( delay + jitter ))
[ "$sleep_time" -lt 1 ] && sleep_time=1
echo "bb $* failed (exit $exit_code); retry $((attempt + 1))/$MAX_ATTEMPTS in ${sleep_time}s" >&2
sleep "$sleep_time"
attempt=$(( attempt + 1 ))
delay=$(( delay * 2 ))
[ "$delay" -gt "$MAX_DELAY" ] && delay="$MAX_DELAY"
done
Terminal window
chmod +x bb-retry.sh
# Read-only: safe to retry freely.
./bb-retry.sh pr list -w myworkspace -r myrepo --json > prs.json
# Tune the cap for big jobs.
MAX_ATTEMPTS=10 INITIAL_DELAY=5 ./bb-retry.sh pr view 42 --json

Recipe: retry only on a specific error code

Section titled “Recipe: retry only on a specific error code”

To retry rate limits but not authentication failures, run the command with --json and read statusCode off the error envelope. Do not grep the text-mode message for 429: that message is Bitbucket’s own prose and usually contains no digits at all, while any PR title containing “502” would match.

bb-retry-on-rate-limit.sh
#!/bin/bash
#
# Usage:
# ./bb-retry-on-rate-limit.sh pr list -w myworkspace -r myrepo
#
# Appends --json so the error envelope is machine-readable.
set -euo pipefail
MAX_ATTEMPTS=5
attempt=1
delay=2
while true; do
err_file=$(mktemp)
bb "$@" --json 2> "$err_file" && { rm -f "$err_file"; exit 0; }
err_msg=$(cat "$err_file")
rm -f "$err_file"
# statusCode exists on API errors only; empty for validation/config errors.
status=$(printf '%s' "$err_msg" | jq -r '.statusCode // empty' 2>/dev/null || true)
# Retry only on rate-limit / transient gateway errors.
case "$status" in
429|502|503|504)
if [ "$attempt" -ge "$MAX_ATTEMPTS" ]; then
echo "$err_msg" >&2
exit 1
fi
echo "HTTP $status, retrying in ${delay}s..." >&2
sleep "$delay"
delay=$(( delay * 2 ))
attempt=$(( attempt + 1 ))
;;
*)
# Non-transient (or no statusCode at all): fail fast.
echo "$err_msg" >&2
exit 1
;;
esac
done

The wrappers above are the right answer when the built-in retry has been exhausted. They are not the right answer for:

  • Auth failuresAuthentication required. Run 'bb auth login' or set BB_USERNAME and BB_API_TOKEN. (code: 1001). Retrying won’t help; fix the credentials.
  • 404 / not-found errors (code: 2002) — these don’t get more found over time.
  • Validation errors (code: 5001 or 5002, e.g. Option --title is required) — bugs in your script, not transient.

The CLI exits 1 for all of these, same as for transient errors. Branch on the JSON envelope instead. Use jq -r '.statusCode // .code': statusCode is present on API errors, and code covers the rest.