Default to exponential backoff retry (#741)

Co-authored-by: UltralyticsAssistant <[email protected]>
This commit is contained in:
Glenn Jocher
2026-04-17 13:36:09 +02:00
committed by GitHub
co-authored by UltralyticsAssistant
parent 2de6732fb8
commit 000482b900
4 changed files with 182 additions and 23 deletions
+110
View File
@@ -68,6 +68,116 @@ jobs:
echo "Running line 2 with TEST_VAR=$TEST_VAR"
false
- name: Record start (exponential)
id: start_exp
run: echo "t=$(date +%s)" >> "$GITHUB_OUTPUT"
- name: Test exponential backoff timing
uses: ./retry
continue-on-error: true
with:
retries: 3
retry_delay_seconds: 2
backoff: exponential
jitter: false
run: |
# Waits should be 2s, 4s, 8s. Total ~14s between 4 attempts.
echo "Attempt at $(date +%s)"
false
- name: Assert exponential timing (~14s expected)
run: |
elapsed=$(( $(date +%s) - ${{ steps.start_exp.outputs.t }} ))
# Expected waits: 2 + 4 + 8 = 14s (no jitter). Allow generous upper bound for runner overhead.
if [ "$elapsed" -lt 13 ] || [ "$elapsed" -gt 40 ]; then
echo "::error::Exponential timing off: ${elapsed}s, expected ~14s (13-40s range)"
exit 1
fi
echo "OK: ${elapsed}s elapsed (expected ~14s)"
- name: Record start (fixed)
id: start_fixed
run: echo "t=$(date +%s)" >> "$GITHUB_OUTPUT"
- name: Test fixed backoff
uses: ./retry
continue-on-error: true
with:
retries: 2
retry_delay_seconds: 3
backoff: fixed
jitter: false
run: |
echo "Attempt at $(date +%s)"
false
- name: Assert fixed timing (~6s expected)
run: |
elapsed=$(( $(date +%s) - ${{ steps.start_fixed.outputs.t }} ))
# Expected waits: 3 + 3 = 6s (no jitter). Allow runner overhead.
if [ "$elapsed" -lt 6 ] || [ "$elapsed" -gt 25 ]; then
echo "::error::Fixed timing off: ${elapsed}s, expected ~6s (6-25s range)"
exit 1
fi
echo "OK: ${elapsed}s elapsed (expected ~6s)"
- name: Record start (jitter floor)
id: start_jitter
run: echo "t=$(date +%s)" >> "$GITHUB_OUTPUT"
- name: Test jitter never collapses small delay to 0
uses: ./retry
continue-on-error: true
with:
retries: 4
retry_delay_seconds: 1
backoff: fixed
jitter: true
run: |
echo "Attempt at $(date +%s)"
false
- name: Assert jitter floor (>=4s for 4 retries of >=1s each)
run: |
elapsed=$(( $(date +%s) - ${{ steps.start_jitter.outputs.t }} ))
# Without the floor, jitter would round 1s to 0s and the 4 retries would run instantly.
if [ "$elapsed" -lt 4 ]; then
echo "::error::Jitter collapsed small delays: ${elapsed}s elapsed, expected >=4s"
exit 1
fi
echo "OK: ${elapsed}s elapsed (>=4s confirmed)"
- name: Test invalid backoff value rejected
uses: ./retry
continue-on-error: true
id: invalid_backoff
with:
retries: 0
backoff: linear
run: echo "should not run"
- name: Assert invalid backoff failed
run: |
if [ "${{ steps.invalid_backoff.outcome }}" != "failure" ]; then
echo "::error::Expected invalid backoff to fail, got ${{ steps.invalid_backoff.outcome }}"
exit 1
fi
- name: Test invalid numeric input rejected
uses: ./retry
continue-on-error: true
id: invalid_retries
with:
retries: "-1"
run: echo "should not run"
- name: Assert invalid numeric input failed
run: |
if [ "${{ steps.invalid_retries.outcome }}" != "failure" ]; then
echo "::error::Expected invalid retries to fail, got ${{ steps.invalid_retries.outcome }}"
exit 1
fi
test-python:
runs-on: ubuntu-latest
steps:
+3 -3
View File
@@ -115,13 +115,13 @@ Reusable composite actions for common CI/CD tasks. Each can be used independentl
### 1. Retry Action
Retry failed commands with exponential backoff.
Retry failed commands with exponential backoff and jitter.
```yaml
- uses: ultralytics/actions/retry@main
with:
command: npm install
max_attempts: 3
run: npm install
retries: 3
timeout_minutes: 5
```
+16 -12
View File
@@ -19,7 +19,7 @@ steps:
### Advanced Usage
Full configuration with custom retries, timeout, and delay:
Full configuration with custom retries, timeout, backoff, and jitter:
```yaml
steps:
@@ -29,8 +29,10 @@ steps:
python setup.py install
pytest tests/
retries: 2 # Retry twice after initial attempt (3 total runs)
timeout_minutes: 30 # Each attempt times out after 30 minutes
retry_delay_seconds: 60 # Wait 60 seconds between retries
timeout_minutes: 30 # Total timeout across all attempts
retry_delay_seconds: 10 # Base delay between retries
backoff: exponential # exponential (10s, 20s, 40s, ...) or fixed
jitter: true # Randomize delay to 80-120% to avoid thundering herd
shell: bash # Use python or bash shell
```
@@ -50,18 +52,20 @@ steps:
## 📋 Inputs
| Input | Description | Required | Default |
| --------------------- | ---------------------------------------- | -------- | ------- |
| `run` | Command to run | Yes | - |
| `retries` | Number of retry attempts after initial | No | `3` |
| `timeout_minutes` | Maximum time in minutes for each attempt | No | `60` |
| `retry_delay_seconds` | Delay between retries in seconds | No | `30` |
| `shell` | Shell to use (bash or python) | No | `bash` |
| Input | Description | Required | Default |
| --------------------- | ------------------------------------------------------------ | -------- | ------------- |
| `run` | Command to run | Yes | - |
| `retries` | Number of retry attempts after initial run | No | `3` |
| `timeout_minutes` | Maximum total time in minutes for all attempts combined | No | `360` |
| `retry_delay_seconds` | Base delay between retries in seconds | No | `10` |
| `backoff` | Backoff strategy: `exponential` (base \* 2^n) or `fixed` | No | `exponential` |
| `jitter` | Randomize delay to 80-120% of value to avoid thundering herd | No | `true` |
| `shell` | Shell to use (`bash` or `python`) | No | `bash` |
## ✨ Features
- Preserves environment variables and step context
- Configurable timeout per attempt
- Exponential backoff with retry delay
- Exponential backoff with ±20% jitter (best-practice defaults)
- Configurable total timeout across all attempts (individual sleeps auto-capped to remaining budget)
- GitHub Actions grouping for retry attempts
- Supports both Bash and Python shells
+53 -8
View File
@@ -15,10 +15,12 @@
# run: |
# python setup.py install
# pytest tests/
# retries: 2 # Retry twice after initial attempt (3 total runs)
# timeout_minutes: 30 # Total timeout for all attempts combined
# retry_delay_seconds: 60 # Wait 60 seconds between retries
# shell: bash # Use python or bash shell
# retries: 2 # Retry twice after initial attempt (3 total runs)
# timeout_minutes: 30 # Total timeout for all attempts combined
# retry_delay_seconds: 10 # Base delay between retries in seconds
# backoff: exponential # exponential (10s, 20s, 40s, ...) or fixed
# jitter: true # Randomize delay to 80-120% to avoid thundering herd
# shell: bash # Use python or bash shell
name: "Step-Level Retry"
description: "Retries a step while preserving its full context"
@@ -32,9 +34,17 @@ inputs:
required: false
default: "3"
retry_delay_seconds:
description: "Delay between retries in seconds"
description: "Base delay between retries in seconds"
required: false
default: "30"
default: "10"
backoff:
description: "Backoff strategy: exponential (base * 2^n) or fixed. Delays are bounded by timeout_minutes."
required: false
default: "exponential"
jitter:
description: "Randomize delay to 80-120% of computed value to spread retries and avoid thundering herd"
required: false
default: "true"
run:
description: "Command to run"
required: true
@@ -60,10 +70,23 @@ runs:
max_attempts=$(( 1 + ${{ inputs.retries }} )) # Initial run + retries
retries=${{ inputs.retries }}
retry_delay=${{ inputs.retry_delay_seconds }}
backoff="${{ inputs.backoff }}"
jitter="${{ inputs.jitter }}"
timeout_mins=${{ inputs.timeout_minutes }}
shell_type="${{ inputs.shell }}"
exit_code=0
# Validate backoff strategy
if [ "$backoff" != "exponential" ] && [ "$backoff" != "fixed" ]; then
echo "::error::Invalid backoff '$backoff'; must be 'exponential' or 'fixed'"
exit 1
fi
# Validate numeric inputs are non-negative integers
case "$retries$retry_delay$timeout_mins" in
''|*[!0-9]*) echo "::error::retries, retry_delay_seconds, timeout_minutes must be non-negative integers"; exit 1 ;;
esac
# Create temporary script file with appropriate extension
if [ "$shell_type" = "python" ]; then
if ! TEMP_SCRIPT=$(mktemp --suffix=.py 2>/dev/null); then
@@ -137,7 +160,29 @@ runs:
exit "$exit_code"
fi
echo "Retrying in $retry_delay seconds..."
sleep "$retry_delay"
# Compute delay for the upcoming retry
if [ "$backoff" = "exponential" ]; then
delay=$((retry_delay * (2 ** (attempt - 1))))
else
delay=$retry_delay
fi
# Apply jitter: random 80-120% of computed delay (±20%)
if [ "$jitter" = "true" ] && [ "$delay" -gt 0 ]; then
low=$((delay * 8 / 10))
span=$((delay * 4 / 10))
rand=$((RANDOM % (span + 1)))
delay=$((low + rand))
[ "$delay" -lt 1 ] && delay=1 # integer division floor; never collapse to 0
fi
# Cap sleep at remaining timeout budget; no point sleeping past deadline.
# Refresh timestamp — the attempt may have taken significant time.
remaining=$((timeout_seconds - ($(date +%s) - start_time)))
[ "$delay" -gt "$remaining" ] && delay=$remaining
[ "$delay" -lt 0 ] && delay=0
echo "Retrying in $delay seconds..."
sleep "$delay"
attempt=$((attempt + 1))
done