Conversation
remipac
marked this pull request as ready for review
August 18, 2026 14:08
Linear rejects requests over the hourly quota with a `Ratelimited` error, and
the SDK already parses the response's `Retry-After` into
`RatelimitedLinearError.retryAfter`. Nothing read it. All three `LinearClient`
constructions pass a bare `{ accessToken }`, and the one place that intercepts
the transport handles 401 only — a 429 was rethrown immediately.
That failure then disappears. Most Linear calls in Cyrus sit behind
`catch → log → return null`, so a rate-limited read returns empty and a
rate-limited activity post is simply lost. The worst case is at session start,
where a dropped prompt leaves nothing in Linear at all: the session looks hung
rather than failed.
`withLinearRateLimitRetry` retries only rate-limit errors, honouring
`Retry-After` as a floor with added jitter, falling back to exponential backoff
with equal jitter when Linear sends no header. Retrying is safe here because a
429 is a pre-execution rejection — Linear declines the request without applying
it, so a retried mutation cannot double-post.
The waiting is bounded on purpose, since a parked session is worse than a failed
one: at most 4 attempts, 30s for any single wait, 60s cumulative. A `Retry-After`
longer than the single-wait cap fails fast instead of sleeping.
The transport patch now installs whenever the GraphQL client exists rather than
only when OAuth config is supplied. Token-authenticated clients previously had no
retry at all, and Linear's quota is per token however that token was obtained.
The 401 refresh path is unchanged in behaviour but now retries via
`originalRequest`, so it no longer re-enters the wrapper or nests a second
backoff budget.
Refs cyrusagents#1324
remipac
force-pushed
the
fix/linear-rate-limit-backoff
branch
from
August 31, 2026 22:58
e5dfd7d to
d485a72
Compare
Author
|
@pauravhp applied the same treatment you asked for on #1412, preemptively (rebased single commit
Ready for review. |
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.
Fixes #1324.
Problem
Linear rejects requests over the hourly quota with a
Ratelimitederror. The SDKalready parses the response's
Retry-Afterheader intoRatelimitedLinearError.retryAfter— nothing reads it.All three
LinearClientconstructions pass a bare{ accessToken }: no retry,no middleware. The one place that intercepts the transport
(
LinearIssueTrackerService) handles 401 only; a 429 is rethrownimmediately, on the first occurrence.
And then the failure disappears, because almost every Linear call in Cyrus sits
behind
catch → log → return null. So a rate-limited session does not stop — itdegrades invisibly:
nothing posted to Linear at all — the session looks hung rather than failed
On a self-hosted deployment this is not theoretical. Measured over one retained
window: 41,742 rate-limit log lines, 28,266 of them inside a single 6-hour
window, and ~7,844 unhandled promise rejections across four days. Two ~2-hour
agent sessions on the same day both stalled and needed manual intervention — one
sat for over an hour unable to read a comment that had been posted to it, the
other could not post its output at all. Across 1,845,515 log records in an 8-hour
window there were zero authentication errors, which rules out token expiry
and leaves the quota as the sole cause.
Fix
A small
withLinearRateLimitRetryhelper, wrapped around the GraphQL transport.existing error handling is unaffected. Detection covers the SDK's parsed
type, HTTP 429, and rate limiting reported through GraphQLerrorson anHTTP 200 response.
Retry-Afteras a floor, plus a little jitter so concurrentcallers do not resume in lockstep. Waiting less than Linear asked just burns
another request. Without the header it falls back to exponential backoff with
equal jitter.
the request without applying it — so a retried
agentActivityCreatecannotdouble-post. This is why the retry can be transport-level rather than
hand-placed on read paths only.
attempts, 30s for any single wait, 60s cumulative. If Linear asks for longer
than the single-wait cap — an exhausted hourly quota can mean minutes — the
request fails fast rather than sleeping through the session.
The transport patch now installs whenever the underlying GraphQL client exists,
rather than only when OAuth config is supplied. Token-authenticated clients
previously had no retry of any kind, and Linear's quota is per token however that
token was obtained. The 401 refresh path keeps its existing behaviour, but now
retries via
originalRequestso it no longer re-enters the wrapper or nests asecond backoff budget.
Tests
packages/linear-event-transport— 49 passed (4 files), of which 25 are new:test/rateLimitRetry.test.ts(20) — detection across all three error shapes,Retry-Afterparsing, the delay formula (floor, jitter bounds, exponentialfallback, cap), and the retry loop: success without sleeping, retry then
succeed, give up and rethrow the original error, never retry non-rate-limit
errors, fail fast on a long
Retry-After, stop at the cumulative budget, andthe log lines.
test/LinearIssueTrackerService.rateLimit.test.ts(5) — the wiring: arate-limited request retries with no OAuth config (the case that had no
retry before), the error is still eventually rethrown after a bounded 4
attempts, non-rate-limit errors are not retried, 401 refresh still works, and a
429 does not trigger a spurious token refresh.
Sleep and randomness are injected, so no test waits on real time.
Verification
Designed, not proven end-to-end. I have not exercised this against a live
Linear API returning real 429s — the retry behaviour is verified against error
objects shaped like
RatelimitedLinearError, and theRetry-Aftersemantics aretaken from the SDK's own
error.d.ts. Reproducing genuine quota exhaustionagainst a real workspace is not something I could do safely.
Scope
Deliberately excluded:
posts) and the payload-id fix in fix(edge-worker): stop re-fetching activities to read an id the payload already has #1412 both cut how fast the quota is consumed.
This PR is only about surviving the limit once it is hit; the three are
complementary and independent.
catch → log → return nullfirefar less often, but a genuinely exhausted quota still ends in a silent
nullwith no indication in Linear. Making Linear-write failures visible to the user
(and to the session) is a broader change than this PR should carry — happy to
open a separate issue for it if that would be useful.