189 lines
6.7 KiB
YAML
189 lines
6.7 KiB
YAML
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
|
|
|
# Example usage:
|
|
#
|
|
# Basic usage (will retry failed step 3 times):
|
|
# steps:
|
|
# - uses: ultralytics/actions/retry@main
|
|
# with:
|
|
# run: python train.py
|
|
#
|
|
# Advanced usage:
|
|
# steps:
|
|
# - uses: ultralytics/actions/retry@main
|
|
# with:
|
|
# 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: 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"
|
|
inputs:
|
|
timeout_minutes:
|
|
description: "Maximum total time in minutes for all attempts (checked between attempts; does not interrupt a running attempt)"
|
|
required: false
|
|
default: "360"
|
|
retries:
|
|
description: "Number of retry attempts after initial run"
|
|
required: false
|
|
default: "3"
|
|
retry_delay_seconds:
|
|
description: "Base delay between retries in seconds"
|
|
required: false
|
|
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
|
|
shell:
|
|
description: "Shell to use (bash or python)"
|
|
required: false
|
|
default: "bash"
|
|
|
|
runs:
|
|
using: "composite"
|
|
steps:
|
|
- name: Execute with retry
|
|
shell: bash
|
|
env:
|
|
# Note: Workflow env vars are automatically inherited. Only RETRY_COMMANDS is added here.
|
|
RETRY_COMMANDS: ${{ inputs.run }}
|
|
run: |
|
|
set +e # Don't exit on error - we handle errors manually
|
|
|
|
start_time=$(date +%s)
|
|
timeout_seconds=$(( ${{ inputs.timeout_minutes }} * 60 ))
|
|
attempt=1
|
|
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
|
|
# Fallback: create temp file then rename it
|
|
if ! TEMP_SCRIPT=$(mktemp 2>/dev/null); then
|
|
echo "::error::Failed to create temporary file"
|
|
exit 1
|
|
fi
|
|
if ! mv "$TEMP_SCRIPT" "${TEMP_SCRIPT}.py"; then
|
|
rm -f "$TEMP_SCRIPT"
|
|
echo "::error::Failed to rename temporary file"
|
|
exit 1
|
|
fi
|
|
TEMP_SCRIPT="${TEMP_SCRIPT}.py"
|
|
fi
|
|
else
|
|
if ! TEMP_SCRIPT=$(mktemp 2>/dev/null); then
|
|
echo "::error::Failed to create temporary file"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# Ensure cleanup on exit
|
|
trap 'rm -f "$TEMP_SCRIPT"' EXIT
|
|
|
|
# Write the user's commands to the temp script
|
|
printf '%s\n' "$RETRY_COMMANDS" > "$TEMP_SCRIPT"
|
|
|
|
# Validate script was written
|
|
if [ ! -s "$TEMP_SCRIPT" ]; then
|
|
echo "::error::No commands provided to execute"
|
|
exit 1
|
|
fi
|
|
|
|
while [ "$attempt" -le "$max_attempts" ]; do
|
|
if [ "$attempt" -gt 1 ]; then
|
|
echo "::group::Retry $((attempt - 1)) of $retries"
|
|
fi
|
|
|
|
current_time=$(date +%s)
|
|
elapsed=$((current_time - start_time))
|
|
if [ "$elapsed" -gt "$timeout_seconds" ]; then
|
|
echo "::error::Step timed out after $timeout_mins minutes"
|
|
[ "$attempt" -gt 1 ] && echo "::endgroup::"
|
|
exit 1
|
|
fi
|
|
|
|
# Execute the script with appropriate interpreter
|
|
if [ "$shell_type" = "python" ]; then
|
|
python3 "$TEMP_SCRIPT"
|
|
else
|
|
bash -e "$TEMP_SCRIPT"
|
|
fi
|
|
exit_code=$?
|
|
|
|
if [ "$exit_code" -eq 0 ]; then
|
|
[ "$attempt" -gt 1 ] && echo "::endgroup::"
|
|
exit 0
|
|
fi
|
|
|
|
# Handle failure
|
|
if [ "$attempt" -gt 1 ]; then
|
|
echo "Retry $((attempt - 1)) failed with exit code $exit_code"
|
|
echo "::endgroup::"
|
|
else
|
|
echo "Initial attempt failed with exit code $exit_code"
|
|
fi
|
|
|
|
if [ "$attempt" -eq "$max_attempts" ]; then
|
|
echo "::error::Step failed after initial attempt and $((attempt - 1)) retries"
|
|
exit "$exit_code"
|
|
fi
|
|
|
|
# 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
|