Skip to content

fix(e2e): fix stale lock recovery and improve lock debugging - #1612

Merged
waynesun09 merged 4 commits into
mainfrom
fix/e2e-lock-debug
May 28, 2026
Merged

fix(e2e): fix stale lock recovery and improve lock debugging#1612
waynesun09 merged 4 commits into
mainfrom
fix/e2e-lock-debug

Conversation

@ralphbean

@ralphbean ralphbean commented May 27, 2026

Copy link
Copy Markdown
Member

Summary

  • Root cause: APIErrorDetail was missing the Message field, so GitHub's 422 "name already exists on this account" was never included in the error string. isRepoAlreadyExists() never matched for the live client, causing tryCreateLock to return an error instead of (false, nil) for existing repos.
  • Second bug: acquireOrg skipped tryReclaimStaleLock when tryCreateLock returned an error (the continue jumped past the stale check). Now stale lock recovery runs even when tryCreateLock fails with an unexpected error.
  • Logging: Added logging for DeleteRepo failures (previously silently discarded with _ =), each step of lock acquisition, and stale lock threshold values.

Test plan

  • make go-test passes
  • make go-vet passes
  • make lint passes
  • New TestAPIError_ErrorStringWithDetails validates error detail messages are included in Error() output
  • CI e2e test should now recover the stale lock and proceed

🤖 Generated with Claude Code

Two bugs prevented stale e2e lock recovery from working with the live
GitHub API:

1. APIErrorDetail was missing the Message field, so GitHub's 422
   response ("name already exists on this account") was not included
   in the error string. This caused isRepoAlreadyExists to never match
   for the live client, making tryCreateLock return an error instead of
   (false, nil) for existing repos.

2. In acquireOrg, when tryCreateLock returned an error, the code
   skipped tryReclaimStaleLock entirely (via continue). Now stale lock
   recovery runs even when tryCreateLock fails with an unexpected error.

Also adds logging throughout the lock management code:
- Log DeleteRepo failures instead of silently discarding them
- Log each step of lock acquisition (create attempt, write, race loss)
- Include staleLockTimeout value in stale lock log messages

Signed-off-by: Ryan Bean <rbean@redhat.com>
Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Ralph Bean <rbean@redhat.com>
@github-actions

github-actions Bot commented May 27, 2026

Copy link
Copy Markdown

Site preview

Preview: https://fded991a-site.fullsend-ai.workers.dev

Commit: 1b0e86698dbdb5845ad75cea8c12539644a35639

@fullsend-ai-review

fullsend-ai-review Bot commented May 27, 2026

Copy link
Copy Markdown

Review

Findings

No findings.

All three fixes verified against the diff:

  1. APIErrorDetail.Message field: Now included in Error() output, so isRepoAlreadyExists() correctly matches GitHub's 422 "name already exists on this account" detail message.
  2. Stale lock recovery path: acquireOrg now calls tryReclaimStaleLock even when tryCreateLock returns an error — but only for 422s (existing repo), correctly excluding rate limits and auth failures from wasting API quota on stale lock checks.
  3. DeleteRepo error logging: All previously-silent _ = client.DeleteRepo(...) calls now log failures. tryReclaimStaleLock additionally returns false on non-404 delete failure, preventing a doomed lock re-creation attempt.

Secondary rate limit detection by response body ("secondary rate limit") and the longer backoff (secondaryRateLimitBackoff) are correctly implemented with proper body lifecycle management in isRetryable/do. The Retry-After header is now bounded (1–300s), and the body is correctly replaced via io.NopCloser(bytes.NewReader(respBody)) when isRetryable reads but doesn't retry a 403. Test coverage is solid.

Previous run

Review

Findings

No findings.

All three fixes verified against the diff:

  1. APIErrorDetail.Message field: Now included in Error() output, so isRepoAlreadyExists() correctly matches GitHub's 422 "name already exists on this account" detail message.
  2. Stale lock recovery path: acquireOrg now calls tryReclaimStaleLock even when tryCreateLock returns an error (previously the continue skipped the stale check).
  3. DeleteRepo error logging: All previously-silent _ = client.DeleteRepo(...) calls now log failures. tryReclaimStaleLock additionally returns false on delete failure, preventing a doomed lock re-creation attempt.

Secondary rate limit detection by response body ("secondary rate limit") and the longer backoff (secondaryRateLimitBackoff) are correctly implemented with proper body lifecycle management in isRetryable/do. Test coverage is solid.

Previous run (2)

Review

No findings.

Previous run (3)

Review

No findings.

Previous run

Review

Findings

No findings.

All three fixes verified against the diff:

  1. APIErrorDetail.Message field: Now included in Error() output, so isRepoAlreadyExists() correctly matches GitHub's 422 "name already exists on this account" detail message.
  2. Stale lock recovery path: acquireOrg now calls tryReclaimStaleLock even when tryCreateLock returns an unexpected error (previously the continue skipped the stale check).
  3. DeleteRepo error logging: All previously-silent _ = client.DeleteRepo(...) calls now log failures. tryReclaimStaleLock additionally returns false on delete failure, preventing a doomed lock re-creation attempt.

Secondary rate limit detection by response body ("secondary rate limit") and the longer backoff (secondaryRateLimitBackoff) are correctly implemented with proper body lifecycle management in isRetryable/do. Test coverage is solid.

Previous run (2)

Review

No findings.

Previous run (3)

Review

No findings.

@fullsend-ai-review fullsend-ai-review Bot added the ready-for-merge All reviewers approved — ready to merge label May 27, 2026
GitHub's secondary rate limit can return 403 without a Retry-After
header. The previous code only retried 403 responses that had a
Retry-After header, so these "body-only" secondary rate limits were
treated as permission errors and not retried.

Now isRetryable reads the 403 response body and checks for "secondary
rate limit" text. When detected, retryDelay uses a 60-second minimum
backoff (plus exponential), matching GitHub's recommendation.

For non-rate-limit 403s (permission denied), the body is preserved and
returned to the caller without retry — no behavior change for those.

Signed-off-by: Ryan Bean <rbean@redhat.com>
Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Ralph Bean <rbean@redhat.com>
@fullsend-ai-review fullsend-ai-review Bot added ready-for-merge All reviewers approved — ready to merge and removed ready-for-merge All reviewers approved — ready to merge labels May 27, 2026
When retries are exhausted, include the method, path, last delay, and
Retry-After header value (if present) in the error message. This makes
rate limit failures visible in e2e test logs without adding a logger
dependency to the HTTP layer.

Signed-off-by: Ryan Bean <rbean@redhat.com>
Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Ralph Bean <rbean@redhat.com>
@fullsend-ai-review fullsend-ai-review Bot added ready-for-merge All reviewers approved — ready to merge and removed ready-for-merge All reviewers approved — ready to merge labels May 27, 2026

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review Squad Report — 5 agents (claude-coder x2, claude-researcher, gemini-code-review, cursor-code-review)

Findings: 6 MEDIUM, 6 LOW, 3 INFO (after dedup from 40+ raw findings, 7 false positives removed)

The root cause fix (APIErrorDetail.Message field) and stale lock recovery logic are correct. The 6 MEDIUM findings are all hardening suggestions — none block the fix. Strongest consensus items (4/5 agents agreed):

  • Stale lock recovery should be gated on 422 errors, not all errors
  • DeleteRepo failure handling should be consistent between acquireLock and tryReclaimStaleLock

Comment thread internal/forge/github/github.go Outdated
return true, nil
}
// Check body for secondary rate limit without Retry-After header.
data, _ := io.ReadAll(resp.Body)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[MEDIUM] Unbounded io.ReadAll + swallowed error (3/5 agents flagged)

Two issues on this line:

  1. Unbounded readio.ReadAll(resp.Body) has no size limit. A malformed or proxy-intercepted 403 with a large body could exhaust memory.

  2. Swallowed error — the read error is discarded (_ =). If the body read fails (connection reset), data is empty/partial, strings.Contains returns false, and (false, data) is returned. The caller replaces resp.Body with this corrupt data, causing checkStatus to produce a confusing "unexpected end of JSON" error instead of surfacing the actual network problem.

Suggestion:

data, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) // 64KB max
if readErr != nil {
    return false, nil
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1b0e866. LimitReader at 64KB and return false, nil on read error so the caller doesn't get corrupt data.

Comment thread internal/forge/github/github.go Outdated
}
// Check body for secondary rate limit without Retry-After header.
data, _ := io.ReadAll(resp.Body)
if strings.Contains(string(data), "secondary rate limit") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[MEDIUM] Case-sensitive substring match is fragile

strings.Contains(string(data), "secondary rate limit") is case-sensitive. If GitHub changes the casing or phrasing (e.g., "Secondary Rate Limit", "secondary_rate_limit"), this detection silently fails and the 403 is treated as a permanent permission error — no retry.

Suggestion:

if strings.Contains(strings.ToLower(string(data)), "secondary rate limit") {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Folded into the same fix — strings.ToLower before the match now.

Comment thread e2e/admin/testutil.go
// returns "422 Validation Failed" without "already exists" in
// the top-level message). Still attempt stale lock recovery.
if token != "" {
if reclaimed := tryReclaimStaleLock(ctx, client, token, org, runID, logf); reclaimed {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[MEDIUM] Stale lock recovery called on any error type — wastes API quota (4/5 agents flagged, strongest consensus)

When tryCreateLock returns an error (rate limit, auth failure, network timeout), this unconditionally calls tryReclaimStaleLock, which issues 2-3 more API calls that will likely also fail. This wastes API quota and could accelerate rate limit exhaustion. tryReclaimStaleLock degrades gracefully (returns false), so it's not a correctness bug — just unnecessary work.

Same pattern repeats at line 122.

Suggestion — gate on error type so only 422s trigger stale recovery:

var apiErr *github.APIError
if token != "" && errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnprocessableEntity {
    if reclaimed := tryReclaimStaleLock(ctx, client, token, org, runID, logf); reclaimed {
        return org, nil
    }
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, gated on 422 via errors.As in 1b0e866. Both sites in acquireOrg now check apiErr.StatusCode == 422 before attempting stale recovery.

Comment thread e2e/admin/lock.go Outdated
logf("[org-pool] %s lock is stale (age: %s > %s), deleting stale lock repo", org, age.Round(time.Second), staleLockTimeout)
if delErr := client.DeleteRepo(ctx, org, lockRepo); delErr != nil {
logf("[org-pool] Warning: failed to delete stale lock repo %s/%s: %v", org, lockRepo, delErr)
return false

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[MEDIUM] Inconsistent DeleteRepo error handling vs acquireLock (4/5 agents flagged)

Here in tryReclaimStaleLock, a DeleteRepo failure causes immediate return false, abandoning the org. But in acquireLock (lines 84-87), the same failure is logged and tryCreateLock still runs. This inconsistency means:

  • If another runner concurrently deletes the stale repo, DeleteRepo returns 404 here, and tryReclaimStaleLock gives up even though tryCreateLock would succeed.
  • In acquireLock, the same scenario correctly falls through to tryCreateLock.

Suggestion — check if the error is a 404 (repo already gone) and proceed in that case:

if delErr := client.DeleteRepo(ctx, org, lockRepo); delErr != nil {
    if !forge.IsNotFound(delErr) {
        logf("[org-pool] Warning: failed to delete stale lock repo %s/%s: %v", org, lockRepo, delErr)
        return false
    }
    logf("[org-pool] Stale lock repo %s/%s already deleted", org, lockRepo)
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch — forge.IsNotFound already exists so this was easy. 1b0e866 now lets 404 fall through to tryCreateLock.

@@ -156,6 +191,10 @@ func retryDelay(resp *http.Response, attempt int) time.Duration {
return time.Duration(secs) * time.Second

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[MEDIUM] Retry-After header parsed without upper bound

strconv.Atoi(ra) has no sanity check. A malformed response with Retry-After: 999999999 would cause the client to sleep for ~31 years. While unlikely from GitHub directly, a misconfigured proxy could produce this.

Suggestion — cap at a reasonable max:

if secs, err := strconv.Atoi(ra); err == nil && secs > 0 && secs <= 300 {
    return time.Duration(secs) * time.Second
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Capped at 300s in 1b0e866.

Address review feedback on #1612:

- Bound io.ReadAll with LimitReader (64KB) and handle read errors in
  isRetryable to avoid unbounded memory use and corrupt body passthrough
- Use case-insensitive match for secondary rate limit body detection
- Cap Retry-After header at 300s to prevent unreasonable sleep durations
- Gate stale lock recovery on 422 errors only — rate limits, auth
  failures, and network errors would just waste more API quota
- Allow 404 on DeleteRepo in tryReclaimStaleLock so concurrent runners
  that already cleaned up the stale lock don't block re-creation

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 added ready-for-merge All reviewers approved — ready to merge and removed ready-for-merge All reviewers approved — ready to merge labels May 27, 2026
@ralphbean
ralphbean requested a review from waynesun09 May 27, 2026 21:12

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

All 6 findings from the previous review have been addressed:

  • io.LimitReader bounds the body read in isRetryable
  • readErr check added after body read
  • Case-insensitive strings.ToLower() for secondary rate limit detection
  • Retry-After clamped to secs > 0 && secs <= 300
  • Stale lock reclaim gated on apiErr.StatusCode == 422
  • forge.IsNotFound guard added in tryReclaimStaleLock

Retry logic is solid, error handling is well-scoped. LGTM.

@waynesun09
waynesun09 added this pull request to the merge queue May 28, 2026
Merged via the queue into main with commit b838db9 May 28, 2026
19 of 22 checks passed
@waynesun09
waynesun09 deleted the fix/e2e-lock-debug branch May 28, 2026 17:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-merge All reviewers approved — ready to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants