⚠️ Definition of Done: this issue must be completed in full, in a single PR. Do not split this
work across multiple PRs, and do not defer any Deliverable below to a follow-up issue. A PR that
satisfies only some of the Deliverables, stubs a required test, or leaves a checkbox
partially-done does NOT resolve this issue and will be closed.
Context
linearGraphQl (src/integrations/linear-adapter.ts:24-36) is the sole HTTP entry point for
every LinearAdapter method — it does one raw fetch() with a 10s timeout
(LINEAR_FETCH_TIMEOUT_MS) and throws immediately on any non-OK response:
async function linearGraphQl<T>(apiKey: string, query: string, variables: Record<string, unknown>): Promise<T> {
const response = await fetch(LINEAR_API_URL, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: apiKey },
body: JSON.stringify({ query, variables }),
signal: AbortSignal.timeout(LINEAR_FETCH_TIMEOUT_MS),
});
if (!response.ok) throw new Error(`Linear API HTTP ${response.status}`);
...
}
There is no retry of any kind — a transient Linear 429 hard-fails on the very first attempt.
LinearAdapter implements the same ProjectTrackerAdapter interface as the GitHub-backed
adapters in src/integrations/project-tracker-adapter.ts, which are meant to be interchangeable
(project-tracker-adapter.ts:27-31's doc comment), and resolveTrackerMatches treats both paths
symmetrically (.catch(() => []) fail-open). But every GitHub-backed call in that file goes
through makeInstallationOctokit → timeoutFetch → fetchWithGitHubRetry
(src/github/client.ts:457-486), which explicitly detects a rate limit
(isRateLimitedResponse, src/github/client.ts:411-419 — a 403/429 with a Retry-After header,
an exhausted x-ratelimit-remaining, or a secondary-limit/abuse response body) and retries with
Retry-After-aware exponential backoff (rateLimitRetryMs, src/github/client.ts:426-433,
capped via GITHUB_RATE_LIMIT_MAX_RETRIES/GITHUB_RATE_LIMIT_MAX_DELAY_MS) before surfacing the
failure. src/gittensor/api.ts (a separate raw-fetch third-party API module in this same
codebase) also has its own bounded timeout/retry handling for this reason.
As a result, a transient Linear rate-limit degrades straight to "no suggestion" for the whole
adapter on the very first blip, while the equivalent transient GitHub rate limit is absorbed and
still produces a correct suggestion in the common case — a real, mechanically verifiable asymmetry
between two adapters of the same interface family.
test/unit/linear-adapter.test.ts:85-91 ("listOpenProjects throws on an HTTP-level failure")
currently asserts an immediate-throw-on-503 as correct — that assertion is fine and must keep
passing (503 is not a rate-limit signal); the gap is that no test anywhere exercises a
429-then-success retry path, confirming this is untested as well as unhandled.
Requirements
- Add a bounded retry to
linearGraphQl for a 429 response (Linear's GraphQL API returns 429
with a standard Retry-After header for rate limiting): on 429, wait per Retry-After if
present and valid, else an exponential backoff, then retry the same request — capped at a fixed
small number of retries (mirror the shape of GITHUB_RATE_LIMIT_MAX_RETRIES/
rateLimitRetryMs in src/github/client.ts:411-433, adapted to Linear's own status/header
semantics; you do not need to reuse GitHub's exact functions, since Linear is a different host —
a local, adapted equivalent in this file is correct, matching this file's own convention of
local per-adapter constants like LINEAR_FETCH_TIMEOUT_MS).
- Any non-
429 non-OK response (e.g. the existing 503 test case) must continue to throw
immediately on the first attempt — no behavior change for a genuine error.
- Once retries are exhausted, the existing
throw new Error(\Linear API HTTP ${response.status}`)`
behavior must still apply.
- Total added latency must stay bounded (mirroring
LINEAR_FETCH_TIMEOUT_MS's existing "never
hang the caller" intent) — pick a small retry cap and a capped backoff ceiling, not unbounded
retry.
Deliverables
All of the above Deliverables are required in the same PR.
Test Coverage Requirements
99%+ Codecov patch coverage, branch-counted, on every changed/added line in
src/integrations/linear-adapter.ts, including both the retry-then-succeed and
retry-exhausted-then-throw branches.
Expected Outcome
A transient Linear 429 self-heals via a bounded retry with backoff, matching the reliability bar
src/github/client.ts's fetchWithGitHubRetry already sets for the GitHub-backed adapters this
module is meant to be interchangeable with — instead of the whole LinearAdapter degrading to "no
suggestion" on the very first rate-limit blip.
Links & Resources
src/integrations/linear-adapter.ts:24-36 (linearGraphQl, the function to fix)
src/github/client.ts:411-433 (isRateLimitedResponse/rateLimitRetryMs), :457-486
(fetchWithGitHubRetry) — the precedent for the retry shape
src/integrations/project-tracker-adapter.ts:27-31 (the interchangeable-adapter contract this
fix brings LinearAdapter in line with)
test/unit/linear-adapter.test.ts:85-91 (existing non-retry coverage, must keep passing)
Context
linearGraphQl(src/integrations/linear-adapter.ts:24-36) is the sole HTTP entry point forevery
LinearAdaptermethod — it does one rawfetch()with a 10s timeout(
LINEAR_FETCH_TIMEOUT_MS) and throws immediately on any non-OK response:There is no retry of any kind — a transient Linear
429hard-fails on the very first attempt.LinearAdapterimplements the sameProjectTrackerAdapterinterface as the GitHub-backedadapters in
src/integrations/project-tracker-adapter.ts, which are meant to be interchangeable(
project-tracker-adapter.ts:27-31's doc comment), andresolveTrackerMatchestreats both pathssymmetrically (
.catch(() => [])fail-open). But every GitHub-backed call in that file goesthrough
makeInstallationOctokit→timeoutFetch→fetchWithGitHubRetry(
src/github/client.ts:457-486), which explicitly detects a rate limit(
isRateLimitedResponse,src/github/client.ts:411-419— a 403/429 with aRetry-Afterheader,an exhausted
x-ratelimit-remaining, or a secondary-limit/abuse response body) and retries withRetry-After-aware exponential backoff (rateLimitRetryMs,src/github/client.ts:426-433,capped via
GITHUB_RATE_LIMIT_MAX_RETRIES/GITHUB_RATE_LIMIT_MAX_DELAY_MS) before surfacing thefailure.
src/gittensor/api.ts(a separate raw-fetch third-party API module in this samecodebase) also has its own bounded timeout/retry handling for this reason.
As a result, a transient Linear rate-limit degrades straight to "no suggestion" for the whole
adapter on the very first blip, while the equivalent transient GitHub rate limit is absorbed and
still produces a correct suggestion in the common case — a real, mechanically verifiable asymmetry
between two adapters of the same interface family.
test/unit/linear-adapter.test.ts:85-91("listOpenProjects throws on an HTTP-level failure")currently asserts an immediate-throw-on-503 as correct — that assertion is fine and must keep
passing (503 is not a rate-limit signal); the gap is that no test anywhere exercises a
429-then-success retry path, confirming this is untested as well as unhandled.
Requirements
linearGraphQlfor a429response (Linear's GraphQL API returns429with a standard
Retry-Afterheader for rate limiting): on429, wait perRetry-Afterifpresent and valid, else an exponential backoff, then retry the same request — capped at a fixed
small number of retries (mirror the shape of
GITHUB_RATE_LIMIT_MAX_RETRIES/rateLimitRetryMsinsrc/github/client.ts:411-433, adapted to Linear's own status/headersemantics; you do not need to reuse GitHub's exact functions, since Linear is a different host —
a local, adapted equivalent in this file is correct, matching this file's own convention of
local per-adapter constants like
LINEAR_FETCH_TIMEOUT_MS).429non-OK response (e.g. the existing503test case) must continue to throwimmediately on the first attempt — no behavior change for a genuine error.
throw new Error(\Linear API HTTP ${response.status}`)`behavior must still apply.
LINEAR_FETCH_TIMEOUT_MS's existing "neverhang the caller" intent) — pick a small retry cap and a capped backoff ceiling, not unbounded
retry.
Deliverables
linearGraphQlretries a429response up to a small, fixed cap, honoringRetry-Afterwhen present and falling back to a capped exponential backoff otherwise.
429non-OK response (e.g.503) still throws immediately with no retry — verify theexisting test at
test/unit/linear-adapter.test.ts:85-91still passes unmodified.429-then-200sequence succeeds (i.e. the adapter methodreturns the successful result, not a thrown error) after exactly one retry.
429exhausting the retry cap still throwsLinear API HTTP 429(or an equivalent error), matching this file's existing thrown-errorshape.
All of the above Deliverables are required in the same PR.
Test Coverage Requirements
99%+ Codecov patch coverage, branch-counted, on every changed/added line in
src/integrations/linear-adapter.ts, including both the retry-then-succeed andretry-exhausted-then-throw branches.
Expected Outcome
A transient Linear
429self-heals via a bounded retry with backoff, matching the reliability barsrc/github/client.ts'sfetchWithGitHubRetryalready sets for the GitHub-backed adapters thismodule is meant to be interchangeable with — instead of the whole
LinearAdapterdegrading to "nosuggestion" on the very first rate-limit blip.
Links & Resources
src/integrations/linear-adapter.ts:24-36(linearGraphQl, the function to fix)src/github/client.ts:411-433(isRateLimitedResponse/rateLimitRetryMs),:457-486(
fetchWithGitHubRetry) — the precedent for the retry shapesrc/integrations/project-tracker-adapter.ts:27-31(the interchangeable-adapter contract thisfix brings
LinearAdapterin line with)test/unit/linear-adapter.test.ts:85-91(existing non-retry coverage, must keep passing)