Repository navigation
feat: retry rate-limited requests with bounded backoff - #49
Closed
seraph-pixelperfect wants to merge 1 commit into
Closed
seraph-pixelperfect wants to merge 1 commit into
seraph-pixelperfect wants to merge 1 commit into
Conversation
- 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
Collaborator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 structuredRATE_LIMITEDerror thatmapLinearErroralready produces. Transport-level only: no caller, flag, or CLI-surface change.Retry policy
429only.mapLinearErrorderivesRATE_LIMITEDfrom the HTTP status alone — there is no GraphQL-error-type rate-limit mapping insrc/errors.ts— so HTTP 429 is the single retry signal. GraphQL-level errors (HTTP 200 +errorsarray) are never retried.mapLinearError({status: 429, body})path — same code, message, hint (Wait ~60s before retrying), and SDK exit code as before, unchanged and not duplicated.Retry-Afterresponse header (delay-seconds) is honored and clamped to [1s, 60s] (so a hostile/garbage header cannot stall the CLI; e.g.Retry-After: 3600waits 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).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 realHeaderssoheaders.get('retry-after')works). Delays use vitest fake timers withtoFake: ['setTimeout']— chosen over an injectable sleep because it keepslinearRequest's signature at zero API surface change; faking onlysetTimeoutleavesDate.now()real, so every backoff test also asserts it finished in real milliseconds, proving no actual sleep ran.vi.advanceTimersByTimeAsyncis 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):
The exhausted-retries case asserts the surfaced error is identical to
mapLinearError({status: 429, body: null})on code, message, suggestions, andexitCodeForError— i.e. the retry adds nothing and changes nothing about the error contract.Local gates (real output)
format:checkis 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 newtest/rate-limit-retry.test.tspassesprettier --check; the lines added tosrc/linear.tsare 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.ymlonly 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 emptystepswith ~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
mapLinearErrormaps toRATE_LIMITED); there is no rate-limit GraphQL-error mapping inmapLinearError, so none was retried. If Linear ever signals via 200 + a rate-limit-typederrorsentry, a mapping + retry condition would need adding there first.--no-retryescape hatch and jitter are noted as possible follow-ups, out of scope here.linearRequest(apiKey, query, variables)unchanged (zero API surface); tests fake onlysetTimeoutso real wall-clock assertions prove no true sleeping.Identity note
Automated agent dispatch authenticated as
seraph-pixelperfect, for human review — not self-approved. Merge is a human decision.