Skip to content

Retry wrapper for transient failures

The CLI retries HTTP 429, 502, 503 and 504 up to three times with exponential backoff. GET, HEAD and OPTIONS also retry transient network errors ECONNRESET, ETIMEDOUT, ECONNABORTED, EAI_AGAIN and EPIPE. HTTP and network retries share that three-retry budget.

DNS ENOTFOUND, connection refusal and certificate/TLS errors are not retried. Fix the address, connectivity or certificate configuration rather than adding attempts.

Use a wrapper when a read-only batch needs a longer retry window. See built-in resilience for the CLI behavior.

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 initial request and up to three retries fail, 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.

The wrappers buffer stdout and publish it only after a successful attempt. Failed attempts cannot leave partial JSON in the report file. stderr still reports retry progress.

#!/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}"
output_file=$(mktemp)
trap 'rm -f "$output_file"' EXIT
attempt=1
delay="$INITIAL_DELAY"
while true; do
# Capture the failed command status before another command replaces it.
bb "$@" > "$output_file" && { cat "$output_file"; 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: successful output goes to the report file.
./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
output_file=$(mktemp)
err_file=$(mktemp)
trap 'rm -f "$output_file" "$err_file"' EXIT
attempt=1
delay=2
while true; do
bb "$@" --json > "$output_file" 2> "$err_file" && {
cat "$output_file"
cat "$err_file" >&2
exit 0
}
err_msg=$(cat "$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 first wrapper retries every nonzero exit, including permanent errors. Prefer the status-filtered wrapper for unattended jobs. Neither wrapper fixes:

  • Auth failures; Authentication required. Run 'bb auth login'. (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.