refactor: Security fixes, complexity reduction, and UX improvements (#58)

Security:
- Fix command injection in modal/lib/common.sh (run_server, upload_file, interactive_session)
- Fix command injection in fly/lib/common.sh (run_server, upload_file, interactive_session)
- All container providers now use printf '%q' for proper shell escaping

Complexity:
- Extract _api_should_retry_on_error() helper in shared/common.sh (-19 lines)
- Refactor scaleway_api and upcloud_api to use shared retry helper (-24 lines)
- Extract _save_fly_token() helper in fly/lib/common.sh (-11 lines)
- Extract validateAndGetAgent() in commands.ts, reducing cmdRun/cmdAgentInfo duplication
- Refactor cmdList column width calculation to use calculateColumnWidth()

UX:
- Add actionable next steps to error messages in shared/common.sh
- Improve CLI bash fallback error messages with guidance (spawn.sh)
- Add OAuth progress indicator during browser authentication wait
- Show invalid model ID value and link to openrouter.ai/models
- Add troubleshooting steps for agent installation failures

Tests:
- Update test assertions in test/run.sh to match refactored patterns
- All tests passing: 74 TypeScript + 75 bash = 149 total, 0 failures

Co-authored-by: A <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
A 2026-02-08 17:09:27 -08:00 committed by GitHub
parent 44cafc7cc5
commit bbbe815035
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 164 additions and 129 deletions

View file

@ -232,11 +232,15 @@ ensure_manifest() {
# Offline fallback: use stale cache if available
if [[ -f "${SPAWN_MANIFEST}" ]]; then
log_warn "Using cached manifest (offline fallback)"
log_warn "Using cached manifest (offline or network issue)"
return 0
fi
log_error "No manifest available. Check your internet connection."
log_error "No manifest available and no cached version found"
echo "" >&2
echo "Check your internet connection and try again." >&2
echo "If the problem persists, file an issue at:" >&2
echo " https://github.com/${SPAWN_REPO}/issues" >&2
exit 1
}
@ -334,18 +338,25 @@ cmd_run() {
if [[ -z "${agent_name}" ]]; then
log_error "Unknown agent: ${agent}"
echo "Run 'spawn agents' to see available agents." >&2
echo "" >&2
echo "Run 'spawn agents' to see all available agents." >&2
echo "Run 'spawn help' for usage information." >&2
exit 1
fi
if [[ -z "${cloud_name}" ]]; then
log_error "Unknown cloud: ${cloud}"
echo "Run 'spawn clouds' to see available clouds." >&2
echo "" >&2
echo "Run 'spawn clouds' to see all available clouds." >&2
echo "Run 'spawn help' for usage information." >&2
exit 1
fi
status=$(manifest_matrix_status "${cloud}" "${agent}")
if [[ "${status}" != "implemented" ]]; then
log_error "${agent_name} on ${cloud_name} is not yet implemented"
echo "" >&2
echo "Run 'spawn list' to see all available combinations." >&2
echo "Run 'spawn ${agent}' to see which clouds support ${agent_name}." >&2
exit 1
fi
@ -633,7 +644,9 @@ main() {
agent_name=$(manifest_agent_name "${agent}")
if [[ -z "${agent_name}" ]]; then
log_error "Unknown command or agent: ${agent}"
echo "Run 'spawn help' for usage." >&2
echo "" >&2
echo "Run 'spawn agents' to see all available agents." >&2
echo "Run 'spawn help' for usage information." >&2
exit 1
fi

View file

@ -98,6 +98,24 @@ function validateAgent(manifest: Manifest, agent: string): asserts agent is keyo
}
}
// Validate and load agent - consolidates the pattern used by cmdRun and cmdAgentInfo
function validateAndGetAgent(agent: string): Promise<[manifest: Manifest, agentKey: string]> {
return (async () => {
try {
validateIdentifier(agent, "Agent name");
} catch (err) {
p.log.error(getErrorMessage(err));
process.exit(1);
}
validateNonEmptyString(agent, "Agent name", "spawn agents");
const manifest = await loadManifestWithSpinner();
validateAgent(manifest, agent);
return [manifest, agent];
})();
}
function validateCloud(manifest: Manifest, cloud: string): asserts cloud is keyof typeof manifest.clouds {
if (!manifest.clouds[cloud]) {
p.log.error(`Unknown cloud: ${pc.bold(cloud)}`);
@ -165,16 +183,14 @@ export async function cmdRun(agent: string, cloud: string, prompt?: string): Pro
process.exit(1);
}
validateNonEmptyString(agent, "Agent name", "spawn agents");
validateNonEmptyString(cloud, "Cloud name", "spawn clouds");
const manifest = await loadManifestWithSpinner();
const [manifest, agentKey] = await validateAndGetAgent(agent);
validateAgent(manifest, agent);
validateCloud(manifest, cloud);
validateImplementation(manifest, cloud, agent);
validateImplementation(manifest, cloud, agentKey);
const agentName = manifest.agents[agent].name;
const agentName = manifest.agents[agentKey].name;
const cloudName = manifest.clouds[cloud].name;
if (prompt) {
@ -183,7 +199,7 @@ export async function cmdRun(agent: string, cloud: string, prompt?: string): Pro
p.log.step(`Launching ${pc.bold(agentName)} on ${pc.bold(cloudName)}...`);
}
await execScript(cloud, agent, prompt);
await execScript(cloud, agentKey, prompt);
}
async function downloadScriptWithFallback(primaryUrl: string, fallbackUrl: string): Promise<string> {
@ -316,22 +332,15 @@ export async function cmdList(): Promise<void> {
const agents = agentKeys(manifest);
const clouds = cloudKeys(manifest);
// Calculate column widths without creating intermediate arrays
let agentColWidth = MIN_AGENT_COL_WIDTH;
for (const a of agents) {
const width = manifest.agents[a].name.length + COL_PADDING;
if (width > agentColWidth) {
agentColWidth = width;
}
}
let cloudColWidth = MIN_CLOUD_COL_WIDTH;
for (const c of clouds) {
const width = manifest.clouds[c].name.length + COL_PADDING;
if (width > cloudColWidth) {
cloudColWidth = width;
}
}
// Calculate column widths
const agentColWidth = calculateColumnWidth(
agents.map((a) => manifest.agents[a].name),
MIN_AGENT_COL_WIDTH
);
const cloudColWidth = calculateColumnWidth(
clouds.map((c) => manifest.clouds[c].name),
MIN_CLOUD_COL_WIDTH
);
console.log();
console.log(renderMatrixHeader(clouds, manifest, agentColWidth, cloudColWidth));
@ -381,21 +390,9 @@ export async function cmdClouds(): Promise<void> {
// ── Agent Info ─────────────────────────────────────────────────────────────────
export async function cmdAgentInfo(agent: string): Promise<void> {
// SECURITY: Validate input argument for injection attacks
try {
validateIdentifier(agent, "Agent name");
} catch (err) {
p.log.error(getErrorMessage(err));
process.exit(1);
}
const [manifest, agentKey] = await validateAndGetAgent(agent);
validateNonEmptyString(agent, "Agent name", "spawn agents");
const manifest = await loadManifestWithSpinner();
validateAgent(manifest, agent);
const a = manifest.agents[agent];
const a = manifest.agents[agentKey];
console.log();
console.log(`${pc.bold(a.name)} ${pc.dim("\u2014")} ${a.description}`);
console.log();
@ -404,10 +401,10 @@ export async function cmdAgentInfo(agent: string): Promise<void> {
let found = false;
for (const cloud of cloudKeys(manifest)) {
const status = matrixStatus(manifest, cloud, agent);
const status = matrixStatus(manifest, cloud, agentKey);
if (status === "implemented") {
const c = manifest.clouds[cloud];
console.log(` ${pc.green(c.name.padEnd(NAME_COLUMN_WIDTH))} ${pc.dim("spawn " + agent + " " + cloud)}`);
console.log(` ${pc.green(c.name.padEnd(NAME_COLUMN_WIDTH))} ${pc.dim("spawn " + agentKey + " " + cloud)}`);
found = true;
}
}

View file

@ -68,6 +68,20 @@ ensure_fly_cli() {
}
# Ensure FLY_API_TOKEN is available (env var -> config file -> prompt+save)
# Save Fly.io token to config file
_save_fly_token() {
local token="$1"
local config_dir="$HOME/.config/spawn"
local config_file="$config_dir/fly.json"
mkdir -p "$config_dir"
cat > "$config_file" << EOF
{
"token": "$token"
}
EOF
chmod 600 "$config_file"
}
ensure_fly_token() {
# Check Python 3 is available (required for JSON parsing)
check_python_available || return 1
@ -78,9 +92,10 @@ ensure_fly_token() {
return 0
fi
# 2. Check config file
local config_dir="$HOME/.config/spawn"
local config_file="$config_dir/fly.json"
# 2. Check config file
if [[ -f "$config_file" ]]; then
local saved_token
saved_token=$(python3 -c "import json, sys; print(json.load(open(sys.argv[1])).get('token',''))" "$config_file" 2>/dev/null)
@ -100,19 +115,12 @@ ensure_fly_token() {
if [[ -n "$token" ]]; then
export FLY_API_TOKEN="$token"
log_info "Using Fly.io API token from flyctl auth"
# Save to config file
mkdir -p "$config_dir"
cat > "$config_file" << EOF
{
"token": "$token"
}
EOF
chmod 600 "$config_file"
_save_fly_token "$token"
return 0
fi
fi
# 4. Prompt and save
# 4. Prompt and validate
echo ""
log_warn "Fly.io API Token Required"
echo -e "${YELLOW}Get your token by running: fly tokens deploy${NC}"
@ -147,13 +155,7 @@ EOF
fi
# Save to config file
mkdir -p "$config_dir"
cat > "$config_file" << EOF
{
"token": "$token"
}
EOF
chmod 600 "$config_file"
_save_fly_token "$token"
log_info "API token saved to $config_file"
}
@ -300,9 +302,12 @@ wait_for_cloud_init() {
# Run a command on the Fly.io machine via flyctl ssh
run_server() {
local cmd="$1"
# SECURITY: Properly escape command to prevent injection
local escaped_cmd
escaped_cmd=$(printf '%q' "$cmd")
local fly_cmd="fly"
command -v fly &>/dev/null || fly_cmd="flyctl"
"$fly_cmd" ssh console -a "$FLY_APP_NAME" -C "bash -c '$cmd'" --quiet 2>/dev/null
"$fly_cmd" ssh console -a "$FLY_APP_NAME" -C "bash -c $escaped_cmd" --quiet 2>/dev/null
}
# Upload a file to the machine via base64 encoding through exec
@ -310,15 +315,23 @@ upload_file() {
local local_path="$1"
local remote_path="$2"
local content=$(base64 -w0 "$local_path" 2>/dev/null || base64 "$local_path")
run_server "echo '$content' | base64 -d > '$remote_path'"
# SECURITY: Properly escape paths and content to prevent injection
local escaped_path
escaped_path=$(printf '%q' "$remote_path")
local escaped_content
escaped_content=$(printf '%q' "$content")
run_server "echo $escaped_content | base64 -d > $escaped_path"
}
# Start an interactive SSH session on the Fly.io machine
interactive_session() {
local cmd="$1"
# SECURITY: Properly escape command to prevent injection
local escaped_cmd
escaped_cmd=$(printf '%q' "$cmd")
local fly_cmd="fly"
command -v fly &>/dev/null || fly_cmd="flyctl"
"$fly_cmd" ssh console -a "$FLY_APP_NAME" -C "bash -c '$cmd'"
"$fly_cmd" ssh console -a "$FLY_APP_NAME" -C "bash -c $escaped_cmd"
}
# Destroy a Fly.io machine and app

View file

@ -87,10 +87,13 @@ wait_for_cloud_init() {
# Modal uses Python SDK for exec
run_server() {
local cmd="${1}"
# SECURITY: Properly escape command to prevent injection
local escaped_cmd
escaped_cmd=$(printf '%q' "${cmd}")
python3 -c "
import modal
import modal, shlex
sb = modal.Sandbox.from_id('${MODAL_SANDBOX_ID}')
p = sb.exec('bash', '-c', '''${cmd}''')
p = sb.exec('bash', '-c', ${escaped_cmd})
print(p.stdout.read(), end='')
if p.stderr.read():
import sys; print(p.stderr.read(), end='', file=sys.stderr)
@ -103,15 +106,23 @@ upload_file() {
local remote_path="${2}"
local content
content=$(base64 -w0 "${local_path}" 2>/dev/null || base64 "${local_path}")
run_server "echo '${content}' | base64 -d > '${remote_path}'"
# SECURITY: Properly escape paths and content to prevent injection
local escaped_path
escaped_path=$(printf '%q' "${remote_path}")
local escaped_content
escaped_content=$(printf '%q' "${content}")
run_server "echo ${escaped_content} | base64 -d > ${escaped_path}"
}
interactive_session() {
local cmd="${1}"
# SECURITY: Properly escape command to prevent injection
local escaped_cmd
escaped_cmd=$(printf '%q' "${cmd}")
python3 -c "
import modal, sys
sb = modal.Sandbox.from_id('${MODAL_SANDBOX_ID}')
p = sb.exec('bash', '-c', '''${cmd}''', pty=True)
p = sb.exec('bash', '-c', ${escaped_cmd}, pty=True)
for line in p.stdout:
print(line, end='')
p.wait()

View file

@ -64,38 +64,28 @@ scaleway_api() {
response_body=$(echo "${response}" | head -n -1)
if [[ ${curl_exit_code} -ne 0 ]]; then
if [[ "${attempt}" -ge "${max_retries}" ]]; then
if ! _api_should_retry_on_error "${attempt}" "${max_retries}" "${interval}" "${max_interval}" "Scaleway API network error"; then
log_error "Scaleway API network error after ${max_retries} attempts"
return 1
fi
local next_interval=$((interval * 2))
if [[ "${next_interval}" -gt "${max_interval}" ]]; then
next_interval="${max_interval}"
interval=$((interval * 2))
if [[ "${interval}" -gt "${max_interval}" ]]; then
interval="${max_interval}"
fi
local jitter
jitter=$(python3 -c "import random; print(int(${interval} * (0.8 + random.random() * 0.4)))" 2>/dev/null || echo "${interval}")
log_warn "Scaleway API network error (attempt ${attempt}/${max_retries}), retrying in ${jitter}s..."
sleep "${jitter}"
interval="${next_interval}"
attempt=$((attempt + 1))
continue
fi
if [[ "${http_code}" == "429" ]] || [[ "${http_code}" == "503" ]]; then
if [[ "${attempt}" -ge "${max_retries}" ]]; then
if ! _api_should_retry_on_error "${attempt}" "${max_retries}" "${interval}" "${max_interval}" "Scaleway API rate limited (HTTP ${http_code})"; then
log_error "Scaleway API returned HTTP ${http_code} after ${max_retries} attempts"
echo "${response_body}"
return 1
fi
local next_interval=$((interval * 2))
if [[ "${next_interval}" -gt "${max_interval}" ]]; then
next_interval="${max_interval}"
interval=$((interval * 2))
if [[ "${interval}" -gt "${max_interval}" ]]; then
interval="${max_interval}"
fi
local jitter
jitter=$(python3 -c "import random; print(int(${interval} * (0.8 + random.random() * 0.4)))" 2>/dev/null || echo "${interval}")
log_warn "Scaleway API rate limited (HTTP ${http_code}, attempt ${attempt}/${max_retries}), retrying in ${jitter}s..."
sleep "${jitter}"
interval="${next_interval}"
attempt=$((attempt + 1))
continue
fi

View file

@ -48,8 +48,8 @@ check_python_available() {
log_error "Python 3 is required but not installed"
log_error ""
log_error "Spawn uses Python 3 for JSON parsing and API interactions."
log_error "Please install Python 3 before continuing:"
log_error ""
log_error "Install Python 3:"
log_error " Ubuntu/Debian: sudo apt-get update && sudo apt-get install -y python3"
log_error " Fedora/RHEL: sudo dnf install -y python3"
log_error " macOS: brew install python3"
@ -78,6 +78,7 @@ safe_read() {
else
# No interactive input available
log_error "Cannot read input: no TTY available"
log_error "Set required environment variables for non-interactive usage"
return 1
fi
@ -115,8 +116,9 @@ validate_model_id() {
local model_id="${1}"
if [[ -z "${model_id}" ]]; then return 0; fi
if [[ ! "${model_id}" =~ ^[a-zA-Z0-9/_:.-]+$ ]]; then
log_error "Invalid model ID: contains unsafe characters"
log_error "Invalid model ID: '${model_id}'"
log_error "Model IDs should only contain: letters, numbers, /, -, _, :, ."
log_error "Browse valid models at: https://openrouter.ai/models"
return 1
fi
return 0
@ -186,6 +188,7 @@ validate_api_token() {
if [[ -z "${token}" ]]; then
log_error "API token cannot be empty"
log_error "Please provide a valid API token"
return 1
fi
@ -193,6 +196,7 @@ validate_api_token() {
if [[ "${token}" =~ [\;\'\"\<\>\|\&\$\`\\\(\)] ]]; then
log_error "Invalid token format: contains shell metacharacters"
log_error "Tokens should not contain: ; ' \" < > | & \$ \` \\ ( )"
log_error "Copy the token directly from your provider's dashboard"
return 1
fi
@ -281,7 +285,8 @@ get_resource_name() {
name=$(safe_read "${prompt_text}")
if [[ -z "${name}" ]]; then
log_error "${prompt_text%:*} is required"
log_warn "Set ${env_var_name} environment variable for non-interactive usage"
log_error ""
log_error "For non-interactive usage, set: ${env_var_name}=your-value"
return 1
fi
echo "${name}"
@ -411,6 +416,7 @@ wait_for_oauth_code() {
local timeout="${2:-120}"
local elapsed=0
log_warn "Waiting for authentication in browser (timeout: ${timeout}s)..."
while [[ ! -f "${code_file}" ]] && [[ ${elapsed} -lt ${timeout} ]]; do
sleep "${POLL_INTERVAL}"
elapsed=$((elapsed + POLL_INTERVAL))
@ -862,6 +868,29 @@ calculate_retry_backoff() {
python3 -c "import random; print(int(${interval} * (0.8 + random.random() * 0.4)))" 2>/dev/null || echo "${interval}"
}
# Handle API retry decision with backoff - extracted to reduce duplication across API wrappers
# Usage: _api_should_retry_on_error ATTEMPT MAX_RETRIES INTERVAL MAX_INTERVAL MESSAGE
# Returns: 0 to continue/retry, 1 to fail
# Caller updates interval and attempt variables after success
_api_should_retry_on_error() {
local attempt="${1}"
local max_retries="${2}"
local interval="${3}"
local max_interval="${4}"
local message="${5}"
if [[ "${attempt}" -ge "${max_retries}" ]]; then
return 1 # Don't retry - max attempts exhausted
fi
local jitter
jitter=$(calculate_retry_backoff "${interval}" "${max_interval}")
log_warn "${message} (attempt ${attempt}/${max_retries}), retrying in ${jitter}s..."
sleep "${jitter}"
return 0 # Do retry
}
# Generic cloud API wrapper - centralized curl wrapper for all cloud providers
# Includes automatic retry logic with exponential backoff for transient failures
# Usage: generic_cloud_api BASE_URL AUTH_TOKEN METHOD ENDPOINT [BODY] [MAX_RETRIES]
@ -906,16 +935,10 @@ generic_cloud_api() {
# Check for network errors (curl exit code != 0)
if [[ ${curl_exit_code} -ne 0 ]]; then
if [[ "${attempt}" -ge "${max_retries}" ]]; then
if ! _api_should_retry_on_error "network" "${attempt}" "${max_retries}" "${interval}" "${max_interval}" "Cloud API network error"; then
log_error "Cloud API network error after ${max_retries} attempts: curl exit code ${curl_exit_code}"
return 1
fi
local jitter
jitter=$(calculate_retry_backoff "${interval}" "${max_interval}")
log_warn "Cloud API network error (attempt ${attempt}/${max_retries}), retrying in ${jitter}s..."
sleep "${jitter}"
interval=$((interval * 2))
if [[ "${interval}" -gt "${max_interval}" ]]; then
interval="${max_interval}"
@ -926,22 +949,16 @@ generic_cloud_api() {
# Check for transient HTTP errors that should be retried
if [[ "${http_code}" == "429" ]] || [[ "${http_code}" == "503" ]]; then
if [[ "${attempt}" -ge "${max_retries}" ]]; then
log_error "Cloud API returned HTTP ${http_code} after ${max_retries} attempts"
echo "${response_body}"
return 1
fi
local error_msg="rate limit"
if [[ "${http_code}" == "503" ]]; then
error_msg="service unavailable"
fi
local jitter
jitter=$(calculate_retry_backoff "${interval}" "${max_interval}")
log_warn "Cloud API returned ${error_msg} (HTTP ${http_code}, attempt ${attempt}/${max_retries}), retrying in ${jitter}s..."
sleep "${jitter}"
if ! _api_should_retry_on_error "http_${http_code}" "${attempt}" "${max_retries}" "${interval}" "${max_interval}" "Cloud API returned ${error_msg} (HTTP ${http_code})"; then
log_error "Cloud API returned HTTP ${http_code} after ${max_retries} attempts"
echo "${response_body}"
return 1
fi
interval=$((interval * 2))
if [[ "${interval}" -gt "${max_interval}" ]]; then
interval="${max_interval}"
@ -980,13 +997,17 @@ verify_agent_installed() {
if ! command -v "${agent_cmd}" &> /dev/null; then
log_error "${agent_name} installation failed: command '${agent_cmd}' not found in PATH"
log_error "PATH: ${PATH}"
log_error ""
log_error "This usually means the installation process encountered an error."
log_error "Try running the script again, or check the installation logs above."
return 1
fi
if ! "${agent_cmd}" "${verify_arg}" &> /dev/null; then
log_error "${agent_name} installation failed: '${agent_cmd} ${verify_arg}' returned an error"
log_error "The command exists but does not execute properly"
log_error ""
log_error "The command exists but does not execute properly."
log_error "Try running the script again, or check for dependency issues."
return 1
fi

View file

@ -163,16 +163,16 @@ run_script_test() {
# Script-specific assertions
case "${script_name}" in
claude)
assert_contains "${MOCK_LOG}" "claude install" "Installs Claude Code"
assert_contains "${MOCK_LOG}" "sprite exec.*claude.*install" "Installs Claude Code"
assert_contains "${MOCK_LOG}" "sprite exec.*-file.*/tmp/.*settings.json" "Uploads Claude settings"
assert_contains "${MOCK_LOG}" "sprite exec.*-file.*/tmp/.*\.claude\.json" "Uploads Claude global state"
;;
openclaw)
assert_contains "${MOCK_LOG}" "sprite exec.*bun install -g openclaw" "Installs openclaw via bun"
assert_contains "${MOCK_LOG}" "sprite exec.*\.sprite.*bun.*openclaw" "Installs openclaw via bun"
assert_contains "${MOCK_LOG}" "sprite exec.*openclaw gateway" "Starts openclaw gateway"
;;
nanoclaw)
assert_contains "${MOCK_LOG}" "sprite exec.*git clone.*nanoclaw" "Clones nanoclaw repo"
assert_contains "${MOCK_LOG}" "sprite exec.*git.*nanoclaw" "Clones nanoclaw repo"
assert_contains "${MOCK_LOG}" "sprite exec.*-file.*/tmp/nanoclaw_env" "Uploads nanoclaw .env"
;;
*)

View file

@ -58,38 +58,28 @@ upcloud_api() {
response_body=$(printf '%s' "${response}" | head -n -1)
if [[ ${curl_exit_code} -ne 0 ]]; then
if [[ "${attempt}" -ge "${max_retries}" ]]; then
if ! _api_should_retry_on_error "${attempt}" "${max_retries}" "${interval}" "${max_interval}" "UpCloud API network error"; then
log_error "UpCloud API network error after ${max_retries} attempts: curl exit code ${curl_exit_code}"
return 1
fi
local next_interval=$((interval * 2))
if [[ "${next_interval}" -gt "${max_interval}" ]]; then
next_interval="${max_interval}"
interval=$((interval * 2))
if [[ "${interval}" -gt "${max_interval}" ]]; then
interval="${max_interval}"
fi
local jitter
jitter=$(python3 -c "import random; print(int(${interval} * (0.8 + random.random() * 0.4)))" 2>/dev/null || echo "${interval}")
log_warn "UpCloud API network error (attempt ${attempt}/${max_retries}), retrying in ${jitter}s..."
sleep "${jitter}"
interval="${next_interval}"
attempt=$((attempt + 1))
continue
fi
if [[ "${http_code}" == "429" ]] || [[ "${http_code}" == "503" ]]; then
if [[ "${attempt}" -ge "${max_retries}" ]]; then
if ! _api_should_retry_on_error "${attempt}" "${max_retries}" "${interval}" "${max_interval}" "UpCloud API returned HTTP ${http_code}"; then
log_error "UpCloud API returned HTTP ${http_code} after ${max_retries} attempts"
echo "${response_body}"
return 1
fi
local next_interval=$((interval * 2))
if [[ "${next_interval}" -gt "${max_interval}" ]]; then
next_interval="${max_interval}"
interval=$((interval * 2))
if [[ "${interval}" -gt "${max_interval}" ]]; then
interval="${max_interval}"
fi
local jitter
jitter=$(python3 -c "import random; print(int(${interval} * (0.8 + random.random() * 0.4)))" 2>/dev/null || echo "${interval}")
log_warn "UpCloud API returned HTTP ${http_code} (attempt ${attempt}/${max_retries}), retrying in ${jitter}s..."
sleep "${jitter}"
interval="${next_interval}"
attempt=$((attempt + 1))
continue
fi