fix: retry transient GitHub API errors instead of failing the dispatch - #11
Conversation
The workflow-dispatch trigger intermittently fails with a transient HTTP 500
from GitHub ({"message":"Failed to run workflow dispatch","status":"500"}),
which aborts the whole deploy/migrations job with no retry.
The api() helper already had a would-be retry branch, but it was doubly broken:
it only matched the literal string "Server Error" (GitHub's real 500 body does
not contain it, so it fell straight to `exit 1`), and even when matched it never
actually re-issued the request — it echoed "trying again" and returned, which on
the dispatch call leaves the caller polling for a run that was never created.
Replace it with a real bounded retry-with-backoff: capture the HTTP status via
-w and retry network errors, 429 and 5xx with capped exponential backoff, fail
fast on other 4xx, and still surface failure once attempts are exhausted so a
genuine outage is never masked. Attempts/backoff are tunable via
API_MAX_ATTEMPTS / API_RETRY_BASE_SECONDS / API_RETRY_MAX_SECONDS.
Adds a shell test harness (scripted curl stub) covering retry-then-success,
fast-fail on 4xx, exhaustion, transport-error retry, and set -e safety, plus a
CI workflow to run it. Verified under bash and the runtime busybox sh
(alpine:3.15.0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the CodeQL "workflow does not contain permissions" finding. The job only checks out code and runs shell tests, so read-only is sufficient. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No issues found
About Unblocked
Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.
📖 Documentation — Learn more in our docs.
💬 Ask questions — Mention @unblocked to request a review or summary, or ask follow-up questions.
👍 Give feedback — React to comments with 👍 or 👎 to help us improve.
⚙️ Customize — Adjust settings in your preferences.
|
| Filename | Overview |
|---|---|
| entrypoint.sh | Core fix: api() rewritten with correct bounded retry-with-backoff; POSIX-compatible arithmetic and conditional forms are used throughout; the duplicate lets_wait() present in an earlier iteration has been removed; TWAW_SOURCE_ONLY guard is safe and correctly scoped. |
| tests/api_retry.test.sh | Six scenarios cover success-after-retry, fast-fail on 4xx, exhausted retries, transport error recovery, 429 retry, and backoff capping; temp files cleaned via trap; assertion logic is clear and correct. |
| tests/api_retry_seterr.test.sh | Dedicated set -e compatibility test for the retry loop; correctly leaves set -e active after sourcing and wraps api() in an if-subshell to prevent harness abort; trap added for temp file cleanup. |
| tests/stub/curl | Faithful curl stub that handles both the old (--fail-with-body) and new (-o / -w) calling conventions; bash-specific parameter substitution is fine since the stub shebang is #!/usr/bin/env bash and bash is installed in the alpine CI step. |
| .github/workflows/test.yaml | Two-step CI: bash run on ubuntu-latest followed by busybox sh run inside alpine:3.15.0 via Docker, which is the actual production shell; apk installs bash so the curl stub shebang resolves correctly inside the container. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A([api called]) --> B[mktemp body_file]
B --> C["curl -sSL -o body_file -w '%{http_code}'"]
C -- "curl exits 0" --> D{http_code?}
C -- "curl exits non-zero" --> E[http_code = 000]
E --> D
D -- "2xx" --> F[echo response\nreturn 0]
D -- "000 / 429 / 5xx" --> G{attempt < max_attempts?}
D -- "other 4xx" --> H[log error\nexit 1]
G -- "yes" --> I["log warning\nsleep delay\nattempt++\ndelay = min(delay*2, max_delay)\ncontinue"]
I --> B
G -- "no (exhausted)" --> H
Reviews (3): Last reviewed commit: "test: lock in 429 + backoff behavior; no..." | Re-trigger Greptile
…an temp files - Remove the dead duplicate lets_wait() (pre-existing; the second definition already overrode it). - CI now also runs the suite under the real runtime shell (busybox sh in alpine:3.15.0 via Docker), not just bash. Running under the ubuntu runner's /bin/sh (dash) is not viable — entrypoint.sh uses ash/bash extensions (process substitution, [[ ]]) that dash cannot parse. - Add EXIT traps so the test harnesses clean up their mktemp scratch files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks for the review @greptile-apps — all three addressed in d833b58:
Verified green under both bash and busybox locally. |
|
All three issues are cleanly addressed. The implementation looks solid:
One minor thing worth a look: the curl stub uses LGTM — this is ready to merge. |
sterfried
left a comment
There was a problem hiding this comment.
Author walk-through — a few inline notes on the non-obvious bits to make review easier.
| -H "Authorization: Bearer ${INPUT_GITHUB_TOKEN}" \ | ||
| -H 'Accept: application/vnd.github.v3+json' \ | ||
| -H 'Content-Type: application/json' \ | ||
| "$@") || http_code=000 |
There was a problem hiding this comment.
The key change vs. the old code: dropped --fail-with-body. We capture the HTTP status via -w '%{http_code}' (body goes to the temp file) and branch on the code below, so a 5xx comes back here as a normal curl success and can be told apart from a 4xx. If curl itself fails (DNS/connection/timeout → non-zero exit, no output), the || http_code=000 maps it to 000, which we treat as a retryable transport error.
| fi | ||
|
|
||
| # Retry transport failures (000), rate limiting (429) and server errors (5xx). | ||
| if [ "$http_code" = "000" ] || [ "$http_code" = "429" ] || [ "$http_code" -ge 500 ]; then |
There was a problem hiding this comment.
Only transient classes retry: transport (000), rate-limit (429), and server errors (5xx). Every other status — i.e. real 4xx client errors — skips this block and falls through to the fail-fast path, so we never retry a genuinely bad request 5×.
| sleep "$delay" | ||
| attempt=$((attempt + 1)) | ||
| delay=$((delay * 2)) | ||
| [ "$delay" -gt "$max_delay" ] && delay=$max_delay |
There was a problem hiding this comment.
Capped exponential backoff: delay starts at API_RETRY_BASE_SECONDS and doubles each attempt, but is clamped to API_RETRY_MAX_SECONDS so it can't grow unbounded.
| fi | ||
| fi | ||
|
|
||
| # Non-retryable error, or retries exhausted: surface the failure. |
There was a problem hiding this comment.
Both terminal paths land here — a non-retryable status, or retries exhausted — and still exit 1. Intentional: the goal is to ride out transient blips, not to swallow a real failure (silently swallowing was half of the old bug).
| # 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 |
There was a problem hiding this comment.
Test-only seam: lets the test harness source this file to call api() directly without running main. TWAW_SOURCE_ONLY is never set in production (the Docker entrypoint runs sh entrypoint.sh), so runtime behavior is unchanged.
|
|
||
| # --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 |
There was a problem hiding this comment.
The stub emulates real curl closely enough that both the old code path (--fail-with-body, body on stdout, exit 22 on ≥400) and the new one (-o file + -w status) work against it. That's what let the same tests fail against the old api() (proving they test the bug) and pass against the new one.
| # 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. |
There was a problem hiding this comment.
entrypoint.sh turns on set -e when sourced; we turn it back off so the harness controls its own flow. api() calls exit on hard failure, so every call is wrapped in a subshell / command substitution to contain that exit — the separate *_seterr* test then re-checks the retry loop with set -e left on, which is how production runs.
| # 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) |
There was a problem hiding this comment.
This second run is the faithful production-shell check: the Docker entrypoint uses busybox sh in alpine:3.15.0, which relies on ash extensions (<(...), [[ ]]) that the runner's /bin/sh (dash) can't parse. Running the suite inside that exact image is the only way to exercise the real runtime shell in CI.
… assumption Follow-ups from code review: - Document that retrying the non-idempotent dispatch POST assumes a pre-creation failure, and why a duplicate would be harmless anyway (idempotent ArgoCD sync + new-run detection in trigger_workflow). - Add an explicit 429 retry test. - Assert the exact backoff sequence (doubling then capping at API_RETRY_MAX_SECONDS) via a sleep stub, so the arithmetic is covered without real delays. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the review follow-ups in 173661f (all were non-blocking niceties):
Suite is now 6 assertions + the set -e test, green under both bash and busybox (alpine:3.15.0) in CI. |
Why
MFD deploys intermittently fail when this action triggers the ArgoCD
update-application.yamlworkflow. The dispatchPOSTgets a transient HTTP 500 from GitHub and the whole deploy/migrations job dies with no retry:Example: cloudbeds/mfd merge-to-main deploy — https://github.com/cloudbeds/mfd/actions/runs/30014504167/job/89231870820 (job
Deploy stage-us2 / Migrations). Every deploy path (merge-main→stage, RC, release, migration-hotfix, to stage and prod) routes through this action, so a single transient 5xx anywhere blocks the deploy and pages the squad.Root cause
api()already had a would-be retry branch, but it was broken two ways and both bit here:"Server Error". GitHub's real dispatch 500 says"Failed to run workflow dispatch"(no match), so it fell straight toexit 1."Server error - trying again"and returned; it never re-issued the request. On the dispatch call that returns empty, leaving the caller polling for a run that was never created (a latent hang).Net effect today: zero resilience to a transient dispatch failure.
What changed
api()as a bounded retry-with-backoff. It captures the HTTP status viacurl -w(instead of--fail-with-body) so it can distinguish failure classes:000),429, and5xx, with capped exponential backoff;4xx(real client errors), no retry;exit 1s, so a genuine outage is never masked.API_MAX_ATTEMPTS(5),API_RETRY_BASE_SECONDS(3),API_RETRY_MAX_SECONDS(30). The retry also covers the pollingGETs, not just the dispatch.Testing
Added a shell test harness (
tests/) driving the realapi()against a scriptedcurlstub, plus a CI workflow (.github/workflows/test.yaml) to run it on every PR. Coverage:API_MAX_ATTEMPTSon a persistent 500 (failure still surfaces);set -e(which production runs with).Verified green under both
bashand the runtime busyboxshinsidealpine:3.15.0(the Dockerfile base).Rollout
This action is consumed by pinned tag. After merge:
v1.6.6).cloudbeds/mfd(.github/actions/trigger-argocd/action.yaml, currently@v1.6.5) to the new tag. Happy to open that PR once the tag exists.Sterling can resume the Claude Code session that produced this work with:
claude --resume 1d0ac1d0-a5f8-48e2-b03e-dcebcf07306c(run from/var/www/phpprojects/mfd)🤖 Generated with Claude Code