-
Notifications
You must be signed in to change notification settings - Fork 0
fix: retry transient GitHub API errors instead of failing the dispatch #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
dc95553
c3c5c30
d833b58
173661f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| name: Test | ||
|
|
||
| on: | ||
| pull_request: | ||
| push: | ||
| branches: | ||
| - main | ||
|
|
||
| jobs: | ||
| shell-tests: | ||
| name: entrypoint.sh tests | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| steps: | ||
| - name: Checkout Code | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Run api() retry tests (bash) | ||
| run: | | ||
| set -e | ||
| for t in tests/*.test.sh; do | ||
| echo "== $t (bash) ==" | ||
| bash "$t" | ||
| done | ||
|
|
||
| # Exercise the real runtime shell. The Docker entrypoint runs the script | ||
| # with busybox `sh` in alpine:3.15.0 (see Dockerfile), which relies on ash | ||
| # extensions the ubuntu runner's /bin/sh (dash) does not support, so run | ||
| # the suite inside that image rather than under the host shell. | ||
| - name: Run api() retry tests (busybox sh, alpine:3.15.0) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This second run is the faithful production-shell check: the Docker entrypoint uses busybox |
||
| run: | | ||
| docker run --rm -v "$PWD":/work -w /work \ | ||
| public.ecr.aws/docker/library/alpine:3.15.0 \ | ||
| sh -c 'apk add --no-cache bash curl jq coreutils >/dev/null && \ | ||
| set -e && \ | ||
| for t in tests/*.test.sh; do echo "== $t (busybox) =="; sh "$t"; done' | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -82,31 +82,73 @@ validate_args() { | |
| fi | ||
| } | ||
|
|
||
| lets_wait() { | ||
| echo "Sleeping for ${wait_interval} seconds" | ||
| sleep "$wait_interval" | ||
| } | ||
|
|
||
| # Perform a GitHub REST API call, retrying transient failures. | ||
| # | ||
| # GitHub occasionally returns transient errors on these endpoints - most notably | ||
| # the workflow-dispatch trigger, which intermittently 500s with the body | ||
| # {"message":"Failed to run workflow dispatch","status":"500"}. Network errors | ||
| # and HTTP 429/5xx are retried with capped exponential backoff. Other 4xx | ||
| # responses are client errors and fail fast, and once retries are exhausted the | ||
| # failure is still surfaced to the caller so a genuine outage is never masked. | ||
| # | ||
| # Tunable via environment (defaults in parentheses): | ||
| # API_MAX_ATTEMPTS total attempts before giving up (5) | ||
| # API_RETRY_BASE_SECONDS first backoff delay; doubles each retry (3) | ||
| # API_RETRY_MAX_SECONDS cap on any single backoff delay (30) | ||
| api() { | ||
| path=$1; shift | ||
| if response=$(curl --fail-with-body -sSL \ | ||
| "${GITHUB_API_URL}/repos/${INPUT_OWNER}/${INPUT_REPO}/actions/$path" \ | ||
| -H "Authorization: Bearer ${INPUT_GITHUB_TOKEN}" \ | ||
| -H 'Accept: application/vnd.github.v3+json' \ | ||
| -H 'Content-Type: application/json' \ | ||
| "$@") | ||
| then | ||
| echo "$response" | ||
| else | ||
| local path=$1; shift | ||
| local max_attempts=${API_MAX_ATTEMPTS:-5} | ||
| local delay=${API_RETRY_BASE_SECONDS:-3} | ||
| local max_delay=${API_RETRY_MAX_SECONDS:-30} | ||
| local attempt=1 | ||
| local body_file http_code response | ||
|
|
||
| while true; do | ||
| body_file=$(mktemp) | ||
| # No --fail-with-body: capture the status code via -w and branch on it, | ||
| # so we can tell a retryable 5xx apart from a fatal 4xx. A transport-level | ||
| # failure (curl exits non-zero, prints nothing) is mapped to code 000. | ||
| http_code=$(curl -sSL \ | ||
| -o "$body_file" \ | ||
| -w '%{http_code}' \ | ||
| "${GITHUB_API_URL}/repos/${INPUT_OWNER}/${INPUT_REPO}/actions/$path" \ | ||
| -H "Authorization: Bearer ${INPUT_GITHUB_TOKEN}" \ | ||
| -H 'Accept: application/vnd.github.v3+json' \ | ||
| -H 'Content-Type: application/json' \ | ||
| "$@") || http_code=000 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The key change vs. the old code: dropped |
||
| response=$(cat "$body_file") | ||
| rm -f "$body_file" | ||
|
|
||
| # Success (2xx): return the response body. | ||
| if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then | ||
| echo "$response" | ||
| return 0 | ||
| fi | ||
|
|
||
| # Retry transport failures (000), rate limiting (429) and server errors (5xx). | ||
| # Retrying the non-idempotent dispatch POST assumes the failure was | ||
| # pre-creation - true for the observed "Failed to run workflow dispatch" 500. | ||
| # Even if a run were somehow created, trigger_workflow keys off newly-appeared | ||
| # runs and an ArgoCD sync is idempotent, so a duplicate is harmless. | ||
| if [ "$http_code" = "000" ] || [ "$http_code" = "429" ] || [ "$http_code" -ge 500 ]; then | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only transient classes retry: transport ( |
||
| if [ "$attempt" -lt "$max_attempts" ]; then | ||
| echo >&2 "api transient failure (HTTP ${http_code}) on ${path}; attempt ${attempt}/${max_attempts}, retrying in ${delay}s" | ||
| [ -n "$response" ] && echo >&2 "response: $response" | ||
| sleep "$delay" | ||
| attempt=$((attempt + 1)) | ||
| delay=$((delay * 2)) | ||
| [ "$delay" -gt "$max_delay" ] && delay=$max_delay | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Capped exponential backoff: |
||
| continue | ||
| fi | ||
| fi | ||
|
|
||
| # Non-retryable error, or retries exhausted: surface the failure. | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both terminal paths land here — a non-retryable status, or retries exhausted — and still |
||
| echo >&2 "api failed:" | ||
| echo >&2 "path: $path" | ||
| echo >&2 "http_code: $http_code" | ||
| echo >&2 "response: $response" | ||
| if [[ "$response" == *'"Server Error"'* ]]; then | ||
| echo >&2 "Server error - trying again" | ||
| else | ||
| exit 1 | ||
| fi | ||
| fi | ||
| exit 1 | ||
| done | ||
| } | ||
|
|
||
| lets_wait() { | ||
|
|
@@ -242,4 +284,9 @@ main() { | |
| fi | ||
| } | ||
|
|
||
| main | ||
| # Allow the script to be sourced by the test harness without executing main. | ||
| # TWAW_SOURCE_ONLY is never set in production (the Docker entrypoint runs the | ||
| # script directly), so the default behaviour is unchanged. | ||
| if [ "${TWAW_SOURCE_ONLY:-}" != "1" ]; then | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test-only seam: lets the test harness |
||
| main | ||
| fi | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| #!/usr/bin/env bash | ||
| # Tests for the transient-error retry behaviour of entrypoint.sh's api() helper. | ||
| # Runs the real api() against a scripted `curl` stub (tests/stub/curl). | ||
| set -u | ||
|
|
||
| HERE=$(cd "$(dirname "$0")" && pwd) | ||
| ROOT=$(cd "$HERE/.." && pwd) | ||
|
|
||
| # Put the curl stub first on PATH; every other tool (jq, sed, mktemp) stays real. | ||
| export PATH="$HERE/stub:$PATH" | ||
|
|
||
| export CURL_STUB_COUNTER | ||
| CURL_STUB_COUNTER=$(mktemp) | ||
| export CURL_STUB_SCENARIO | ||
| CURL_STUB_SCENARIO=$(mktemp) | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
| trap 'rm -f "$CURL_STUB_COUNTER" "$CURL_STUB_SCENARIO"' EXIT | ||
|
|
||
| # Values api() reads to build the request URL (contents irrelevant to the stub). | ||
| export INPUT_OWNER=cloudbeds INPUT_REPO=argocd-mfd INPUT_GITHUB_TOKEN=x | ||
| # Retry knobs: keep attempts small and eliminate real sleeping so tests are fast. | ||
| export API_MAX_ATTEMPTS=4 | ||
| export API_RETRY_BASE_SECONDS=0 | ||
|
|
||
| # Source the script for its functions only; do not run main. | ||
| # shellcheck disable=SC1090 | ||
| TWAW_SOURCE_ONLY=1 . "$ROOT/entrypoint.sh" | ||
| set +e # entrypoint.sh enables `set -e`; disable it so the harness controls flow. | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| fail=0 | ||
| pass() { printf 'ok - %s\n' "$1"; } | ||
| die() { printf 'FAIL - %s\n' "$1"; fail=1; } | ||
|
|
||
| # scenario "<printf-escaped lines>" resets the counter and writes the response script. | ||
| scenario() { : > "$CURL_STUB_COUNTER"; printf '%b' "$1" > "$CURL_STUB_SCENARIO"; } | ||
| attempts() { cat "$CURL_STUB_COUNTER"; } | ||
|
|
||
| S500='500\t{"message":"Failed to run workflow dispatch","status":"500"}' | ||
|
|
||
| # --- Test 1: transient 500s then success -------------------------------------- | ||
| # GitHub's dispatch endpoint intermittently returns a 500 with this exact body | ||
| # (no "Server Error" string). api() must retry and ultimately succeed. | ||
| scenario "$S500\n$S500\n204\t\n" | ||
| # api() calls `exit` on hard failure, so always invoke it in a subshell. | ||
| out=$(api "workflows/update-application.yaml/dispatches" --data '{}' 2>/dev/null); rc=$? | ||
| if [ "$rc" -eq 0 ] && [ "$(attempts)" -eq 3 ]; then | ||
| pass "retries transient 500 then succeeds (3 attempts)" | ||
| else | ||
| die "retries transient 500 then succeeds: rc=$rc attempts=$(attempts) (want rc=0 attempts=3)" | ||
| fi | ||
|
|
||
| # --- Test 2: genuine 4xx fails fast, no retry --------------------------------- | ||
| scenario '404\t{"message":"Not Found"}\n' | ||
| out=$(api "workflows/missing.yaml/dispatches" --data '{}' 2>/dev/null); rc=$? | ||
| if [ "$rc" -ne 0 ] && [ "$(attempts)" -eq 1 ]; then | ||
| pass "does not retry a 4xx (fails fast in 1 attempt)" | ||
| else | ||
| die "does not retry a 4xx: rc=$rc attempts=$(attempts) (want rc!=0 attempts=1)" | ||
| fi | ||
|
|
||
| # --- Test 3: persistent 500 exhausts retries then fails ------------------------ | ||
| scenario "$S500\n$S500\n$S500\n$S500\n$S500\n" | ||
| out=$(api "workflows/update-application.yaml/dispatches" --data '{}' 2>/dev/null); rc=$? | ||
| if [ "$rc" -ne 0 ] && [ "$(attempts)" -eq "$API_MAX_ATTEMPTS" ]; then | ||
| pass "gives up after API_MAX_ATTEMPTS on persistent 500 (surfaces failure)" | ||
| else | ||
| die "persistent 500 exhausts retries: rc=$rc attempts=$(attempts) (want rc!=0 attempts=$API_MAX_ATTEMPTS)" | ||
| fi | ||
|
|
||
| # --- Test 4: transport error (no HTTP response) is retried -------------------- | ||
| scenario 'NETERR\n200\t{"ok":true}\n' | ||
| if out=$(api "runs/123" 2>/dev/null); then rc=0; else rc=$?; fi | ||
| if [ "$rc" -eq 0 ] && [ "$(attempts)" -eq 2 ] && printf '%s' "$out" | grep -q '"ok":true'; then | ||
| pass "retries a transport failure then succeeds (2 attempts)" | ||
| else | ||
| die "retries a transport failure: rc=$rc attempts=$(attempts) out=$out" | ||
| fi | ||
|
|
||
| # --- Test 5: a 429 (rate limit) is retried like a 5xx ------------------------- | ||
| scenario '429\t{"message":"API rate limit exceeded"}\n204\t\n' | ||
| out=$(api "workflows/update-application.yaml/dispatches" --data '{}' 2>/dev/null); rc=$? | ||
| if [ "$rc" -eq 0 ] && [ "$(attempts)" -eq 2 ]; then | ||
| pass "retries a 429 then succeeds (2 attempts)" | ||
| else | ||
| die "retries a 429: rc=$rc attempts=$(attempts) (want rc=0 attempts=2)" | ||
| fi | ||
|
|
||
| # --- Test 6: backoff doubles each retry and caps at API_RETRY_MAX_SECONDS ------ | ||
| # The sleep stub records each requested delay (via SLEEP_LOG) instead of really | ||
| # sleeping, so the backoff sequence can be asserted without waiting. With base 3 | ||
| # and cap 10 over 5 persistent failures, the delays are 3, 6, 10 (12 capped), 10. | ||
| SLEEP_LOG=$(mktemp) | ||
| scenario "$S500\n$S500\n$S500\n$S500\n$S500\n" | ||
| out=$( | ||
| export SLEEP_LOG API_RETRY_BASE_SECONDS=3 API_RETRY_MAX_SECONDS=10 API_MAX_ATTEMPTS=5 | ||
| api "workflows/update-application.yaml/dispatches" --data '{}' 2>/dev/null | ||
| ); rc=$? | ||
| seq=$(tr '\n' ' ' < "$SLEEP_LOG" | sed 's/ *$//') | ||
| rm -f "$SLEEP_LOG" | ||
| if [ "$rc" -ne 0 ] && [ "$(attempts)" -eq 5 ] && [ "$seq" = "3 6 10 10" ]; then | ||
| pass "backoff doubles then caps at API_RETRY_MAX_SECONDS (delays: 3 6 10 10)" | ||
| else | ||
| die "backoff sequence: rc=$rc attempts=$(attempts) delays=[$seq] (want attempts=5 delays='3 6 10 10')" | ||
| fi | ||
|
|
||
| echo "----" | ||
| if [ "$fail" -eq 0 ]; then echo "ALL PASS"; else echo "SOME FAILED"; fi | ||
| exit "$fail" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| #!/bin/sh | ||
| # Production runs entrypoint.sh with `set -e` (see the top of the script). The | ||
| # retry loop uses `A && B` lists and arithmetic that can trip `set -e` if written | ||
| # carelessly, so this test exercises the multi-retry path with `set -e` left ON | ||
| # and asserts the loop runs to completion instead of aborting early. | ||
| set -eu | ||
|
|
||
| HERE=$(cd "$(dirname "$0")" && pwd) | ||
| ROOT=$(cd "$HERE/.." && pwd) | ||
| export PATH="$HERE/stub:$PATH" | ||
|
|
||
| CURL_STUB_COUNTER=$(mktemp); export CURL_STUB_COUNTER | ||
| CURL_STUB_SCENARIO=$(mktemp); export CURL_STUB_SCENARIO | ||
| trap 'rm -f "$CURL_STUB_COUNTER" "$CURL_STUB_SCENARIO"' EXIT | ||
| export INPUT_OWNER=cloudbeds INPUT_REPO=argocd-mfd INPUT_GITHUB_TOKEN=x | ||
| export API_MAX_ATTEMPTS=4 API_RETRY_BASE_SECONDS=0 | ||
|
|
||
| # Sourcing re-enables `set -e`; deliberately do NOT disable it afterwards. | ||
| TWAW_SOURCE_ONLY=1 . "$ROOT/entrypoint.sh" | ||
|
|
||
| S500='500\t{"message":"Failed to run workflow dispatch","status":"500"}' | ||
| : > "$CURL_STUB_COUNTER" | ||
| printf '%b' "$S500\n$S500\n204\t\n" > "$CURL_STUB_SCENARIO" | ||
|
|
||
| # api() calls exit on hard failure and inherits `set -e`; run it in a subshell | ||
| # inside an `if` so neither its exit nor a non-zero status aborts this harness. | ||
| if ( api "workflows/update-application.yaml/dispatches" --data '{}' >/dev/null 2>&1 ); then | ||
| rc=0 | ||
| else | ||
| rc=$? | ||
| fi | ||
| n=$(cat "$CURL_STUB_COUNTER") | ||
|
|
||
| if [ "$rc" -eq 0 ] && [ "$n" -eq 3 ]; then | ||
| echo "ok - retry loop completes cleanly under set -e (3 attempts)" | ||
| exit 0 | ||
| fi | ||
| echo "FAIL - set -e retry path: rc=$rc attempts=$n (want rc=0 attempts=3)" | ||
| exit 1 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| #!/usr/bin/env bash | ||
| # Faithful-enough `curl` stub for exercising entrypoint.sh's api() function. | ||
| # | ||
| # It understands only the flags api() actually uses (--fail-with-body, -o <file>, | ||
| # -w <fmt>) and ignores everything else (-H, -sSL, --data, the URL). Behaviour is | ||
| # driven by two files so a single test can script a sequence of responses: | ||
| # | ||
| # $CURL_STUB_SCENARIO : one line per attempt. Either "<CODE><TAB><BODY>" or the | ||
| # literal token NETERR to simulate a transport failure. | ||
| # $CURL_STUB_COUNTER : running attempt counter (created on first call). | ||
| # | ||
| # It emulates real curl closely enough that both the old (--fail-with-body, body | ||
| # on stdout) and the new (-o file + -w '%{http_code}') implementations work. | ||
| set -u | ||
|
|
||
| fail_with_body=0 | ||
| out_file="" | ||
| w_fmt="" | ||
|
|
||
| while [ $# -gt 0 ]; do | ||
| case "$1" in | ||
| --fail-with-body) fail_with_body=1 ;; | ||
| -o) shift; out_file="$1" ;; | ||
| -w) shift; w_fmt="$1" ;; | ||
| esac | ||
| shift | ||
| done | ||
|
|
||
| n=$(cat "$CURL_STUB_COUNTER" 2>/dev/null || echo 0) | ||
| n=$((n + 1)) | ||
| echo "$n" > "$CURL_STUB_COUNTER" | ||
|
|
||
| line=$(sed -n "${n}p" "$CURL_STUB_SCENARIO") | ||
| if [ "$line" = "NETERR" ] || [ -z "$line" ]; then | ||
| # Transport-level failure (connection reset, DNS, timeout): no HTTP response. | ||
| exit 7 | ||
| fi | ||
|
|
||
| code=$(printf '%s' "$line" | cut -f1) | ||
| body=$(printf '%s' "$line" | cut -f2-) | ||
|
|
||
| # Body goes to the -o file if requested, otherwise to stdout (like real curl). | ||
| if [ -n "$out_file" ]; then | ||
| printf '%s' "$body" > "$out_file" | ||
| else | ||
| printf '%s' "$body" | ||
| fi | ||
|
|
||
| # The -w template is written to stdout with %{http_code} substituted. | ||
| if [ -n "$w_fmt" ]; then | ||
| printf '%s' "${w_fmt//%\{http_code\}/$code}" | ||
| fi | ||
|
|
||
| # --fail-with-body makes real curl exit 22 on HTTP >= 400 (still emitting body). | ||
| if [ "$fail_with_body" -eq 1 ] && [ "$code" -ge 400 ]; then | ||
| exit 22 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The stub emulates real curl closely enough that both the old code path ( |
||
| fi | ||
| exit 0 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| #!/bin/sh | ||
| # Test stub for `sleep`: records the requested duration (when SLEEP_LOG is set) | ||
| # and returns immediately instead of actually sleeping, so backoff timing can be | ||
| # asserted without real delays. When SLEEP_LOG is unset it is a silent no-op. | ||
| [ -n "${SLEEP_LOG:-}" ] && printf '%s\n' "$1" >> "$SLEEP_LOG" | ||
| exit 0 |
Uh oh!
There was an error while loading. Please reload this page.