Skip to content
This repository was archived by the owner on May 30, 2026. It is now read-only.

fix: retry 403 secondary rate limits in GitHubClient resilience - #141

Merged
C. Spencer Beggs (spencerbeggs) merged 1 commit into
mainfrom
feat/updates
May 30, 2026
Merged

C. Spencer Beggs (spencerbeggs) merged 1 commit into
mainfrom
feat/updates

Conversation

@spencerbeggs

@spencerbeggs C. Spencer Beggs (spencerbeggs) commented May 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

GitHubClient's resilience layer previously classified only 429 and >= 500 responses as retryable. GitHub secondary rate limits frequently surface as 403 with a Retry-After header, so those responses failed immediately instead of backing off.

A 403 is now treated as retryable only when it carries a server-advised retry signal — a Retry-After header, or x-ratelimit-remaining: 0 plus x-ratelimit-reset. A bare 403 (genuine permission denial) stays non-retryable so we don't loop on real auth failures. 429 / 5xx behavior is unchanged. The fix reuses the existing parseRetryAfterMs / withResilience backoff machinery.

Changes

  • src/layers/GitHubClientLive.ts — replace status-only isRetryableStatus with isRetryable(status, retryAfterMs); wrapError computes the retry delay once and widens the retryable set to include 403 secondary rate limits.
  • src/layers/GitHubClientLive.test.ts — tests for both retried and non-retried 403 paths (error-wrapping + resilience integration).
  • Design docs (layers.md, errors-and-schemas.md, services.md, testing-strategy.md) and docs/08-resilient-github-api.md updated to describe the new classification.
  • Housekeeping: removed a broken plugin entry from .claude/settings.json; gitignored the AI context cache.

Test plan

  • pnpm vitest run on GitHubClientLive.test.ts and resilience.test.ts — all pass
  • pnpm run typecheck — clean
  • Biome — clean

Closes #139

Signed-off-by: C. Spencer Beggs spencer@savvyweb.systems

A 403 carrying a server-advised retry signal (Retry-After, or x-ratelimit-remaining: 0 with x-ratelimit-reset) is a GitHub secondary rate limit and is now classified retryable, so withResilience backs off and retries it instead of failing immediately.

A bare 403 stays non-retryable so genuine permission denials do not loop. 429 and 5xx behavior is unchanged.

Also removes a broken plugin entry from the Claude settings and gitignores the AI context cache.

Closes #139
Signed-off-by: C. Spencer Beggs <spencer@savvyweb.systems>

@savvy-web-bot savvy-web-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

Current Commit: f71de9c

Summary

Clean, minimal fix. The change is correctly scoped: GitHub secondary rate limits surface as 403s with Retry-After or x-ratelimit-remaining: 0 + x-ratelimit-reset, and this PR makes exactly that distinction — retrying those while leaving bare 403 permission denials as immediate failures.

What's Good

  • Correct classification logic. isRetryable(status, retryAfterMs) neatly captures the three cases (429 always, 5xx always, 403 only with a retry signal). The retryAfterMs !== undefined gate is the right predicate — it's exactly what parseRetryAfterMs returns when a retry signal is present.

  • Eliminates double computation. The old code called parseRetryAfterMs(error) independently for retryable and retryAfterMs. Now it's computed once in wrapError and the result flows into both fields — cleaner and consistent.

  • Solid test coverage. Three unit tests cover all 403 variants at the wrapError level (Retry-After, x-ratelimit-remaining:0, bare 403). Two integration tests verify the resilience layer actually retries a 403+Retry-After to success and stops immediately on a bare 403. The critical paths are all exercised.

  • Changeset included. Patch bump with accurate description.

  • Housekeeping is appropriate. Removing the broken vitest-agent-reporter plugin entry and gitignoring .claude/cache/ are clean, uncontroversial changes.

Checks

Check Status
Tests ✅ success
Code Quality ✅ success
Conventional Commits ✅ success
PR Title Validation ✅ success
Markdown ✅ success

Nit

One inline comment on the new multi-line JSDoc block — non-blocking, see the inline note.

Comment on lines +58 to +65
/**
* Whether a failed response should be retried. `429` and any `5xx` are always
* retryable. A `403` is retryable only when it carries a server-advised retry
* delay (GitHub secondary rate limit) — a bare `403` is a genuine permission
* denial and must fail fast rather than loop.
*/
const isRetryable = (status: number, retryAfterMs: number | undefined): boolean =>
status === 429 || status >= 500 || (status === 403 && retryAfterMs !== undefined);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: CLAUDE.md convention says "never write multi-line comment blocks — one short line max." The WHY here is genuinely non-obvious so a comment is warranted, but it could be condensed to one line, e.g.:

// A 403 is retryable only with a server-advised retry signal (GitHub secondary rate limit).

Non-blocking — the explanation is valuable either way.

@spencerbeggs
C. Spencer Beggs (spencerbeggs) merged commit 6abe152 into main May 30, 2026
21 checks passed
@spencerbeggs
C. Spencer Beggs (spencerbeggs) deleted the feat/updates branch May 30, 2026 01:58
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GitHubClient resilience: treat 403 + Retry-After as retryable (secondary rate limits)

1 participant