Skip to content

feat: retry rate-limited requests with bounded backoff - #49

Closed
seraph-pixelperfect wants to merge 1 commit into
feat/comment-update-deletefrom
feat/rate-limit-retry
Closed

seraph-pixelperfect wants to merge 1 commit into
feat/comment-update-deletefrom
feat/rate-limit-retry

Conversation

@seraph-pixelperfect

Copy link
Copy Markdown
Collaborator

Closes #30
Depends on #48

What

linearRequest (the single transport every operation goes through) now retries HTTP 429 responses with bounded backoff before surfacing the structured RATE_LIMITED error that mapLinearError already produces. Transport-level only: no caller, flag, or CLI-surface change.

Retry policy

  • Trigger: HTTP status 429 only. mapLinearError derives RATE_LIMITED from the HTTP status alone — there is no GraphQL-error-type rate-limit mapping in src/errors.ts — so HTTP 429 is the single retry signal. GraphQL-level errors (HTTP 200 + errors array) are never retried.
  • Attempts: up to 2 retries (3 attempts total), then the final 429 flows into the existing mapLinearError({status: 429, body}) path — same code, message, hint (Wait ~60s before retrying), and SDK exit code as before, unchanged and not duplicated.
  • Backoff: the Retry-After response header (delay-seconds) is honored and clamped to [1s, 60s] (so a hostile/garbage header cannot stall the CLI; e.g. Retry-After: 3600 waits 60s, not an hour). Missing, non-numeric ("soon"), non-positive ("0"), or HTTP-date-form values fall back to a bounded exponential 1s, 2s (capped at 60s for hypothetical later retries).
  • No retry on anything else: a non-429 after a 429 fails immediately with the last response's mapping (e.g. 429→500 surfaces the 500's NETWORK_ERROR), and first-call 401s, network failures, and GraphQL errors take their existing one-shot paths.

Tests

New test/rate-limit-retry.test.ts (11 tests) on the established fetch-stub pattern (vi.stubGlobal('fetch', ...) with Response-like objects carrying real Headers so headers.get('retry-after') works). Delays use vitest fake timers with toFake: ['setTimeout'] — chosen over an injectable sleep because it keeps linearRequest's signature at zero API surface change; faking only setTimeout leaves Date.now() real, so every backoff test also asserts it finished in real milliseconds, proving no actual sleep ran. vi.advanceTimersByTimeAsync is advanced one millisecond at a time to pin the exact honored-delay boundaries (1999ms → no retry; +1ms → retry).

429-then-success and exhausted-retries output (real run, note per-test durations):

 ✓ test/rate-limit-retry.test.ts > 429 then success (Retry-After honored) > retries once after the Retry-After delay and succeeds 2ms
 ✓ test/rate-limit-retry.test.ts > 429 then success (Retry-After honored) > sends the same GraphQL document and variables on the retry 0ms
 ✓ test/rate-limit-retry.test.ts > 429 then success (Retry-After honored) > clamps an oversized Retry-After to 60s 0ms
 ✓ test/rate-limit-retry.test.ts > 429 without a usable Retry-After (exponential fallback) > falls back to 1s then 2s when the header is missing 0ms
 ✓ test/rate-limit-retry.test.ts > 429 without a usable Retry-After (exponential fallback) > treats a non-numeric or zero Retry-After as missing 0ms
 ✓ test/rate-limit-retry.test.ts > exhausted retries surface RATE_LIMITED > stops after 3 attempts and throws mapLinearError’s 429 mapping 0ms
 ✓ test/rate-limit-retry.test.ts > non-429 failures are never retried > fails immediately with the 500 mapping when a 429 retry hits a 500 0ms
 ✓ test/rate-limit-retry.test.ts > non-429 failures are never retried > does not retry a first-call 401 and sets up no timers 0ms
 ✓ test/rate-limit-retry.test.ts > non-429 failures are never retried > does not retry GraphQL-level errors (200 + errors array) 0ms
 ✓ test/rate-limit-retry.test.ts > non-429 failures are never retried > does not retry a network-level fetch failure 0ms
 ✓ test/rate-limit-retry.test.ts > first-call success > returns immediately without setting up any timer 0ms

 Test Files  1 passed (1)
      Tests  11 passed (11)

The exhausted-retries case asserts the surfaced error is identical to mapLinearError({status: 429, body: null}) on code, message, suggestions, and exitCodeForError — i.e. the retry adds nothing and changes nothing about the error contract.

Local gates (real output)

$ pnpm build          # tsc — OK
$ pnpm lint           # eslint . — OK
$ pnpm run build:skill -- --check
skills/linear-axi/SKILL.md is up to date.
$ pnpm test
 Test Files  19 passed (19)
      Tests  225 passed (225)
$ pnpm run format:check
[warn] Code style issues found in 18 files. Run Prettier with --write to fix.
  • format:check is red pre-existing on the base branch: the 18 flagged files are byte-identical to the base set (CI does not run this gate). The new test/rate-limit-retry.test.ts passes prettier --check; the lines added to src/linear.ts are prettier-clean (that file was already in the pre-existing flagged set — verified by stashing this change and re-running).

Stacked PR note

Stacked on #48 (stack: #37→#38→#39→#40→#41→#42→#43→#44→#45→#46→#47→#48). Base is feat/comment-update-delete, so no CI checks appear — ci.yml only triggers on PRs targeting main. CI runs when retargeted to main after the stack merges; local gates above are the evidence. Do not retarget.

CI quota-block caveat

If checks do appear and fail: billing/spending-limit rejections look like check failures. Check gh run view <run-id> / the jobs API for a billing annotation, or empty steps with ~3-4s duration — that indicates quota-block, not a code failure. If quota-blocked, rely on the local output above; do not retry.

Ambiguities / decisions flagged

  • GraphQL-level rate-limit signaling: Linear signals rate limiting via HTTP 429 (which mapLinearError maps to RATE_LIMITED); there is no rate-limit GraphQL-error mapping in mapLinearError, so none was retried. If Linear ever signals via 200 + a rate-limit-typed errors entry, a mapping + retry condition would need adding there first.
  • Delay strategy: Retry-After clamped to [1s, 60s] with 1s/2s exponential fallback — deliberately simple and bounded per the issue ("max 2 retries... BOUNDED and simple"). A --no-retry escape hatch and jitter are noted as possible follow-ups, out of scope here.
  • Fake timers over injectable sleep: keeps linearRequest(apiKey, query, variables) unchanged (zero API surface); tests fake only setTimeout so real wall-clock assertions prove no true sleeping.
  • Mutations retried on 429: a 429 means the request was rejected before execution, so re-sending mutations (create/update/delete) is safe; noted for reviewers.

Identity note

Automated agent dispatch authenticated as seraph-pixelperfect, for human review — not self-approved. Merge is a human decision.

- linearRequest retries HTTP 429 up to 2 times (3 attempts total) before
  surfacing mapLinearError's structured RATE_LIMITED error unchanged —
  transport-level, so every command benefits with no API/flag changes
- backoff honors Retry-After delay-seconds, clamped to [1s, 60s]; missing/
  invalid/non-positive header falls back to bounded exponential (1s, 2s)
- no retry on non-429 outcomes, GraphQL-level errors (200 + errors), or
  network failures; a non-429 after a 429 fails immediately with the last
  response's mapping
- test/rate-limit-retry.test.ts: 11 tests over the fetch-stub pattern with
  fake timers (only setTimeout faked, Date stays real to prove no actual
  sleeping; per-ms advance pins exact honored-delay boundaries)

Closes #30
@ghostinprod-pixelperfect

Copy link
Copy Markdown
Collaborator

Superseded by merged rc/0.2 PR #52 (commit ad6b141), which consolidated this write/transport stack with validation and review fixes.

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