Skip to content

fix(forge): retry 5xx server errors at the HTTP client level - #2342

Merged
ralphbean merged 3 commits into
mainfrom
fix/retry-5xx-in-do
Jun 18, 2026
Merged

fix(forge): retry 5xx server errors at the HTTP client level#2342
ralphbean merged 3 commits into
mainfrom
fix/retry-5xx-in-do

Conversation

@ralphbean

Copy link
Copy Markdown
Member

Summary

  • Moves 5xx (500-504) retry handling from retryOnTransient down into isRetryable in do(), so all GitHub API calls automatically retry on transient server errors
  • Renames retryOnTransientretryOnRepoRace and narrows isTransientStatus to only 404/409, reflecting its actual purpose (repo init races and branch ref conflicts)
  • Fixes the 502 Bad Gateway failure seen in this run, where GetPullRequestHeadSHA had no retry coverage

Test plan

  • New TestIsRetryable_ServerErrors — verifies isRetryable returns true for 500/502/503/504
  • New TestDo_RetriesOnServerError — verifies do() retries a 502 and succeeds on next attempt
  • Updated existing 5xx retry tests to reflect new call patterns (retry happens inside do(), not at the wrapper level)
  • Full ./internal/forge/... test suite passes

🤖 Generated with Claude Code

Move 5xx retry handling from the higher-level retryOnTransient wrapper
(now renamed retryOnRepoRace) down into isRetryable, which is used by
do(). This ensures all GitHub API calls automatically retry on transient
server errors (500-504), not just the handful of call sites that were
wrapped in retryOnTransient.

This fixes a 502 Bad Gateway failure in post-review's
GetPullRequestHeadSHA, which had no retry coverage because it called
get() directly.

Rename retryOnTransient to retryOnRepoRace and narrow isTransientStatus
to only cover 404 (async repo init) and 409 (branch ref conflict), which
are the race conditions that wrapper actually exists for.

Assisted-by: Claude claude-opus-4-6 <noreply@anthropic.com>
Signed-off-by: Ralph Bean <rbean@redhat.com>
@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown

Site preview

Preview: https://8ae8c2d3-site.fullsend-ai.workers.dev

Commit: 1e985c93b2a6e17e55a17460f50d8507903c53f7

@fullsend-ai-review

fullsend-ai-review Bot commented Jun 16, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 4:20 PM UTC · Completed 4:32 PM UTC
Commit: 78302ba · View workflow run →

@codecov

codecov Bot commented Jun 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.61538% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/forge/github/github.go 84.61% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@fullsend-ai-review

fullsend-ai-review Bot commented Jun 16, 2026

Copy link
Copy Markdown

Review

Findings

Medium

  • [logic-error] internal/forge/github/github.go:174 — The new 5xx check resp.StatusCode >= 500 && resp.StatusCode <= 504 inadvertently retries HTTP 501 (Not Implemented), which is not a transient error and was not retried by the previous isTransientStatus implementation (which explicitly listed 500, 502, 503, 504). The doc comment also says "500, 502, 503, 504" but the range includes 501. Retrying a 501 wastes time and delays the eventual error.
    Remediation: Use an explicit switch or check for the four specific codes (500, 502, 503, 504) instead of a range, matching the original behavior and the doc comment.

Low

  • [error-message-consistency] internal/forge/github/github.go:149 — The error message says "retryable error after %d attempts" but line 164 says "exhausted retries". Minor inconsistency, though line 164 is unreachable.

  • [error-message-accuracy] internal/forge/github/github.go:149 — The new error message "retryable error after %d attempts" is generic and loses the distinction between rate limits (429/403) and server errors (5xx). The APIError struct carries the status code programmatically, so this is a minor diagnostic convenience issue.

  • [test-adequacy] internal/forge/github/github_test.go:1429TestCreateOrUpdateFile_MaxRetriesExceeded does not assert the total HTTP call count. Other tests in this PR (e.g., TestCreateOrUpdateFile_RetriesOn504, TestDo_RetriesOnServerError) do assert on call counts, making this omission inconsistent.

Info

  • [naming-alignment] internal/forge/github/github.go:596isTransientStatus now only checks 404/409 repo race conditions. Consider renaming to isRepoRaceStatus for full consistency with retryOnRepoRace.

  • [error-handling-gap] internal/forge/github/github.go:148 — The error message format string has a missing closing parenthesis in the base string, with ) appended later via concatenation. Output is correct but the pattern is fragile. Pre-existing, not introduced by this PR.

  • [terminology-consistency] internal/forge/github/github.go:148 — Error message uses "attempts" but the constant is maxRetries. The semantics are correct (maxRetries=3 means 3 attempts).


Labels: PR fixes a 502 Bad Gateway retry bug in the forge GitHub client.

Previous run

Review

Findings

Critical

  • [logic-error] internal/forge/github/github.go:823 — The diff renames retryOnTransient to retryOnRepoRace but does not update two call sites in DeleteFiles: line 823 (c.retryOnTransient(ctx, "get branch ref", ...)) and line 931 (c.retryOnTransient(ctx, "update ref", ...)). Since the method definition is renamed, these two references will cause a compilation error.
    Remediation: Update both call sites in DeleteFiles to use c.retryOnRepoRace(ctx, ...) instead of c.retryOnTransient(ctx, ...).

Low

  • [test-adequacy] internal/forge/github/github_test.go:1415TestCreateOrUpdateFile_MaxRetriesExceeded should verify the total HTTP call count (assert.Equal(t, 4, callNum) for 1 GET + 3 PUT attempts) to confirm the expected retry behavior matches the new architecture where do() handles 5xx retries.

  • [test-adequacy] internal/forge/github/github_test.go:1440TestDo_RetriesOnServerError incurs real wall-clock delay from the exponential backoff in do(), with no overridable hook (unlike secondaryRateLimitBackoff). This will accumulate test-suite runtime.

  • [scope-coherence] internal/forge/github/github.go — The PR uses fix(forge) prefix but includes architectural refactoring (rename, narrowing isTransientStatus, moving 5xx retry to do()). The fix prefix is defensible since the change addresses a concrete gap where GetPullRequestHeadSHA had no 5xx retry coverage.

Info

  • [missing-authorization] internal/forge/github/github.go — No linked issue for a non-trivial change. The PR body references a specific CI failure which provides sufficient context.

  • [naming-alignment] internal/forge/github/github.goisTransientStatus now only checks 404/409 repo race conditions. Consider renaming to isRepoRaceStatus for full consistency with retryOnRepoRace.

  • [terminology-consistency] internal/forge/github/github.go:148 — Error message uses "attempts" but the constant is maxRetries. The semantics are correct (maxRetries=3 means 3 attempts).

  • [error-handling-gap] internal/forge/github/github.go:148 — The error message format string has a missing closing parenthesis in the base string, with ) appended later via concatenation. Output is correct but the pattern is fragile. Pre-existing, not introduced by this PR.

Previous run (2)

Review

Findings

Medium

  • [error-handling-gap] internal/forge/github/github.go:148 — After this PR, do() retries 5xx errors up to maxRetries (3) times and then returns an APIError with the 5xx status code. retryOnRepoRace calls isTransientStatus which no longer matches 5xx, so there is no double-retry today. However, if someone later re-adds 5xx to isTransientStatus, a 5xx failure would be retried 3 times in do() and then up to 5 more times in retryOnRepoRace (potentially 15 total inner attempts). Adding a comment documenting this invariant would be a low-cost safeguard.
    Remediation: Add a code comment on the APIError returned by do() at line 153 noting that the caller must not retry 5xx because do() has already exhausted retries, or wrap it in a sentinel type that retryOnRepoRace explicitly excludes.

Low

  • [documentation-completeness] internal/forge/github/github.go:170 — The comment for isRetryable() says "Server errors (500, 502, 503, 504)" but the range check resp.StatusCode >= 500 && resp.StatusCode <= 504 also includes 501 (Not Implemented), which is not documented. HTTP 501 is generally not transient and arguably should not be retried.
    Remediation: Either update the comment to list all five codes (500–504 inclusive) or change the condition to explicitly check only the intended codes.

  • [test-adequacy] internal/forge/github/github_test.goTestDo_RetriesOnServerError incurs at least 1s of real wall-clock delay from the exponential backoff in do(), with no overridable hook (unlike secondaryRateLimitBackoff which tests can override). This will accumulate test-suite runtime.

  • [terminology-consistency] internal/forge/github/github.go:148 — Error message uses "attempts" but the constant is maxRetries. Minor inconsistency, though the value semantics are correct.

  • [scope-coherence] internal/forge/github/github.go:147 — The generalized error message "retryable error after %d attempts" is more accurate than the old "rate limited" message now that do() retries more than just rate limits. Consider whether distinguishing rate-limit vs server-error exhaustion would aid debugging.

  • [missing-authorization] internal/forge/github/github.go — No linked issue for a non-trivial change. The PR body references a specific CI failure which provides sufficient context, but linking an issue would improve traceability.

  • [commit-message-alignment] — PR title uses fix(forge) which is defensible since the change corrects a defect (missing retry on GetPullRequestHeadSHA), though the structural refactoring (renaming retryOnTransient, narrowing isTransientStatus) could also support refactor(forge).

Info

  • [architectural-alignment] internal/forge/github/github.go:167 — Moving 5xx retry into do() strengthens the forge abstraction by centralizing retry logic. All forge.Client methods automatically benefit. The separation of concerns — do() for HTTP-level retries, retryOnRepoRace for GitHub-specific race conditions — is architecturally sound.

  • [edge-case] internal/forge/github/github.go:173 — The 500–504 range check correctly excludes 505+ status codes, which are typically non-transient.

@ralphbean
ralphbean added this pull request to the merge queue Jun 18, 2026
@ralphbean
ralphbean removed this pull request from the merge queue due to a manual request Jun 18, 2026
@fullsend-ai-review

fullsend-ai-review Bot commented Jun 18, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 3:28 PM UTC · Completed 3:40 PM UTC
Commit: 8e9ba31 · View workflow run →

@fullsend-ai-review fullsend-ai-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the review comment for full details.

// With a persistent 504 on PUT, do() exhausts its 3 attempts and
// returns immediately — retryOnRepoRace does not retry 5xx.
callNum := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] test-adequacy

TestCreateOrUpdateFile_MaxRetriesExceeded should verify the total HTTP call count to confirm the expected retry behavior matches the new architecture where do() handles 5xx retries.

// only covers race-condition statuses (404 async repo init, 409 ref conflict).
transient := []int{404, 409}
for _, code := range transient {
assert.True(t, isTransientStatus(code), "expected %d to be transient", code)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] test-adequacy

TestDo_RetriesOnServerError incurs real wall-clock delay from the exponential backoff in do(), with no overridable hook (unlike secondaryRateLimitBackoff). This will accumulate test-suite runtime.

@@ -146,7 +146,7 @@ func (c *LiveClient) do(ctx context.Context, method, path string, body any) (*ht
retryAfter := resp.Header.Get("Retry-After")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] scope-coherence

The PR uses fix(forge) prefix but includes architectural refactoring. The fix prefix is defensible since the change addresses a concrete gap where GetPullRequestHeadSHA had no 5xx retry coverage.

@@ -591,16 +597,13 @@ func (c *LiveClient) retryOnTransient(ctx context.Context, label string, fn func
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[info] naming-alignment

isTransientStatus now only checks 404/409 repo race conditions. Consider renaming to isRepoRaceStatus for full consistency with retryOnRepoRace.

@@ -146,7 +146,7 @@ func (c *LiveClient) do(ctx context.Context, method, path string, body any) (*ht
retryAfter := resp.Header.Get("Retry-After")

if attempt == maxRetries-1 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[info] terminology-consistency

Error message uses 'attempts' but the constant is maxRetries. The semantics are correct (maxRetries=3 means 3 attempts).

@@ -146,7 +146,7 @@ func (c *LiveClient) do(ctx context.Context, method, path string, body any) (*ht
retryAfter := resp.Header.Get("Retry-After")

if attempt == maxRetries-1 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[info] error-handling-gap

The error message format string has a missing closing parenthesis in the base string, with ) appended later via concatenation. Output is correct but the pattern is fragile. Pre-existing, not introduced by this PR.

@fullsend-ai-review fullsend-ai-review Bot removed the requires-manual-review Review requires human judgment label Jun 18, 2026
Two call sites in commitFilesTo were missed during the rename, causing
build failures.

Assisted-by: Claude claude-opus-4-6 <noreply@anthropic.com>
Signed-off-by: Ralph Bean <rbean@redhat.com>
@fullsend-ai-review

fullsend-ai-review Bot commented Jun 18, 2026

Copy link
Copy Markdown

🤖 Finished Review · ❌ Failure · Started 4:21 PM UTC · Completed 4:33 PM UTC
Commit: 1e985c9 · View workflow run →

@ralphbean
ralphbean added this pull request to the merge queue Jun 18, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jun 18, 2026
@ralphbean
ralphbean added this pull request to the merge queue Jun 18, 2026
Merged via the queue into main with commit 4d83c42 Jun 18, 2026
10 checks passed
@ralphbean
ralphbean deleted the fix/retry-5xx-in-do branch June 18, 2026 17:51
@fullsend-ai-retro

fullsend-ai-retro Bot commented Jun 18, 2026

Copy link
Copy Markdown

🤖 Finished Retro · ✅ Success · Started 5:56 PM UTC · Completed 6:05 PM UTC
Commit: 1e985c9 · View workflow run →

@fullsend-ai-retro

Copy link
Copy Markdown

Retro: PR #2342 — fix(forge): retry 5xx server errors at the HTTP client level

What happened

Human-authored PR by ralphbean to move 5xx retry handling from retryOnTransient into do(), fixing a 502 Bad Gateway gap where GetPullRequestHeadSHA had no retry coverage. The review agent ran 3 times across 3 commits.

Timeline:

  1. First review (commit 78302ba) — found medium/low findings, no critical issues at this stage.
  2. Second review (commit 8e9ba31) — caught a Critical compilation error: two DeleteFiles call sites still referenced the old retryOnTransient after the rename. This was a genuine, high-value catch that would have broken the build.
  3. Human reviewer approved at 07:32 UTC on June 18.
  4. Third review (commit 1e985c9) — posted CHANGES_REQUESTED at 15:40 UTC (8+ hours after human approval) with only medium/low findings about the >= 500 && <= 504 range including HTTP 501.
  5. One review run (27773457418) failed for unclear reasons.
  6. PR merged at 17:51 UTC.

Assessment

Review quality was strong. The critical compilation error catch on the second review was exactly the kind of finding that justifies automated reviews — it prevented a broken build from merging.

Three patterns worth noting, all covered by existing issues:

  1. Severity inconsistency — The 501 range-check finding was classified Low ("documentation-completeness") in one run and Medium ("logic-error") in the next, on unchanged code. → Already tracked in #1389.
  2. CHANGES_REQUESTED after human approval — The bot posted CHANGES_REQUESTED with only medium/low findings, overriding a human approval made 8 hours earlier. → Already tracked in #2115.
  3. Repeated findings across runs — After the critical was fixed, subsequent reviews largely repeated the same medium/low findings without adding new value. → Covered by #1500 and #963.

No new proposals filed — all identified improvements are already tracked in existing open issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants