@enrichlayer/el-linear 1.44.2 → 1.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,10 @@
1
+ import { LinearClient } from "@linear/sdk";
1
2
  import type { LinearCredential } from "../auth/linear-credential.js";
3
+ import type { OAuthState } from "../auth/oauth-storage.js";
2
4
  import type { GraphQLResponseData, GraphQLVariables } from "../types/linear.js";
3
5
  import type { AuthOptions } from "./auth.js";
6
+ import { type RateLimitInfo } from "./output.js";
7
+ import { type RateLimitAdmission } from "./rate-limit-admission.js";
4
8
  export interface GraphQLRequestOptions {
5
9
  /**
6
10
  * Retry only an operation whose mutation is safe to repeat. Callers must
@@ -13,8 +17,30 @@ export interface GraphQLRequestOptions {
13
17
  interface GraphQLServiceRuntimeOptions {
14
18
  retryDelaysMs?: readonly number[];
15
19
  sleep?: (ms: number) => Promise<void>;
20
+ now?: () => number;
21
+ /** Test/embedding seam; production derives this from EL_LINEAR_RATE_LIMIT_HEADROOM. */
22
+ admission?: RateLimitAdmission;
23
+ /** Reacquire an app-user client-credentials token after HTTP 401. */
24
+ renewAccessToken?: () => Promise<string>;
16
25
  }
17
- /** True only for failures where retrying a known-idempotent request is useful. */
26
+ type OAuthStateRenewal = (state: OAuthState, options: {
27
+ forceOAuthRenewal: true;
28
+ }) => Promise<OAuthState>;
29
+ /**
30
+ * Keep the last persisted client-credentials state in a long-lived service.
31
+ * Each forced 401 renewal must advance from the previous renewal, not from the
32
+ * state captured when the service was constructed.
33
+ */
34
+ export declare function createClientCredentialsTokenRenewal(initialState: OAuthState, renew?: OAuthStateRenewal): (() => Promise<string>) | undefined;
35
+ export declare function extractRateLimitInfo(source: unknown, now: () => number): RateLimitInfo | undefined;
36
+ /** True for both HTTP and GraphQL representations of Linear rate limiting. */
37
+ export declare function isRateLimitGraphQLError(error: unknown): boolean;
38
+ /**
39
+ * True only for failures where retrying a known-idempotent request immediately
40
+ * is useful. This deliberately differs from published `errorDetail.retryable`:
41
+ * a rate limit is transient for an external caller with reset-aware backoff,
42
+ * but must never enter this CLI's 150 ms / 400 ms retry loop.
43
+ */
18
44
  export declare function isTransientGraphQLError(error: unknown): boolean;
19
45
  /**
20
46
  * Constructor arg for `GraphQLService`. Re-exported alias of the shared
@@ -28,11 +54,26 @@ export declare function isTransientGraphQLError(error: unknown): boolean;
28
54
  * semantic change — same `Authorization: <token>` header is emitted).
29
55
  */
30
56
  export type GraphQLServiceAuth = LinearCredential;
57
+ /**
58
+ * Put SDK-backed LinearService calls through the same quota admission and
59
+ * response-header observer as custom GraphQLService calls. The SDK's public
60
+ * `request()` discards response headers, so this narrow transport adapter uses
61
+ * its own public-ish rawRequest escape hatch and returns only `data` to the
62
+ * generated SDK models, preserving their existing contract.
63
+ */
64
+ export declare function instrumentLinearClient(client: LinearClient, auth: GraphQLServiceAuth, options?: {
65
+ admission?: RateLimitAdmission;
66
+ now?: () => number;
67
+ }): LinearClient;
31
68
  export declare class GraphQLService {
32
69
  private readonly graphQLClient;
33
70
  private readonly retryDelaysMs;
34
71
  private readonly sleep;
72
+ private readonly now;
73
+ private readonly admission;
74
+ private readonly renewAccessToken?;
35
75
  constructor(auth: GraphQLServiceAuth, options?: GraphQLServiceRuntimeOptions);
76
+ private observeRateLimit;
36
77
  rawRequest<T = GraphQLResponseData>(query: string, variables?: GraphQLVariables, options?: GraphQLRequestOptions): Promise<T>;
37
78
  }
38
79
  export declare function createGraphQLService(options: AuthOptions): Promise<GraphQLService>;
@@ -1,19 +1,180 @@
1
1
  import { LinearClient } from "@linear/sdk";
2
- import { getActiveAuth } from "../auth/token-resolver.js";
2
+ import { print } from "graphql";
3
+ import { ensureFreshAccessToken, getActiveAuth, } from "../auth/token-resolver.js";
4
+ import { graphQLErrorCode, graphQLErrorHttpStatus, toLinearGraphQLError, } from "./linear-graphql-error.js";
5
+ import { logger } from "./logger.js";
6
+ import { recordRateLimitInfo } from "./output.js";
7
+ import { createOAuthProfileRateLimitAdmission, createRateLimitAdmission, RateLimitAdmissionRefusal, } from "./rate-limit-admission.js";
3
8
  const DEFAULT_SAFE_MUTATION_RETRY_DELAYS_MS = [150, 400];
9
+ /**
10
+ * Keep the last persisted client-credentials state in a long-lived service.
11
+ * Each forced 401 renewal must advance from the previous renewal, not from the
12
+ * state captured when the service was constructed.
13
+ */
14
+ export function createClientCredentialsTokenRenewal(initialState, renew = ensureFreshAccessToken) {
15
+ if (initialState.grantType !== "client_credentials")
16
+ return undefined;
17
+ let currentState = initialState;
18
+ return async () => {
19
+ currentState = await renew(currentState, { forceOAuthRenewal: true });
20
+ return currentState.accessToken;
21
+ };
22
+ }
4
23
  function errorMessage(error) {
5
24
  return error instanceof Error ? error.message : String(error);
6
25
  }
7
- /** True only for failures where retrying a known-idempotent request is useful. */
26
+ function errorDetail(error) {
27
+ return error;
28
+ }
29
+ function errorResponse(error) {
30
+ const detail = errorDetail(error);
31
+ return detail.response ?? detail.raw?.response;
32
+ }
33
+ function headerValue(headers, name) {
34
+ if (!headers)
35
+ return undefined;
36
+ if ("get" in headers && typeof headers.get === "function") {
37
+ return headers.get(name) ?? undefined;
38
+ }
39
+ const expected = name.toLowerCase();
40
+ const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === expected);
41
+ return entry?.[1];
42
+ }
43
+ function finiteNumber(value) {
44
+ if (value === undefined || value === null || value === "")
45
+ return undefined;
46
+ const parsed = typeof value === "number" ? value : Number(value);
47
+ return Number.isFinite(parsed) ? parsed : undefined;
48
+ }
49
+ function resetAtFromRetryAfter(value, now) {
50
+ if (value === undefined)
51
+ return undefined;
52
+ const seconds = finiteNumber(value);
53
+ if (seconds !== undefined) {
54
+ return new Date(now() + seconds * 1_000).toISOString();
55
+ }
56
+ if (typeof value === "string") {
57
+ const timestamp = Date.parse(value);
58
+ if (Number.isFinite(timestamp))
59
+ return new Date(timestamp).toISOString();
60
+ }
61
+ return undefined;
62
+ }
63
+ export function extractRateLimitInfo(source, now) {
64
+ const detail = errorDetail(source);
65
+ const response = errorResponse(source) ?? source;
66
+ const headers = response?.headers;
67
+ const limit = finiteNumber(detail.requestsLimit ?? headerValue(headers, "x-ratelimit-requests-limit"));
68
+ const remaining = finiteNumber(detail.requestsRemaining ??
69
+ headerValue(headers, "x-ratelimit-requests-remaining"));
70
+ const resetTimestamp = finiteNumber(detail.requestsResetAt ??
71
+ headerValue(headers, "x-ratelimit-requests-reset"));
72
+ const resetAt = resetTimestamp !== undefined
73
+ ? new Date(resetTimestamp).toISOString()
74
+ : resetAtFromRetryAfter(detail.retryAfter ?? headerValue(headers, "retry-after"), now);
75
+ const complexityCost = finiteNumber(headerValue(headers, "x-complexity"));
76
+ const complexityLimit = finiteNumber(headerValue(headers, "x-ratelimit-complexity-limit"));
77
+ const complexityRemaining = finiteNumber(headerValue(headers, "x-ratelimit-complexity-remaining"));
78
+ const complexityResetTimestamp = finiteNumber(headerValue(headers, "x-ratelimit-complexity-reset"));
79
+ const endpointName = headerValue(headers, "x-ratelimit-endpoint-name");
80
+ const endpointLimit = finiteNumber(headerValue(headers, "x-ratelimit-endpoint-requests-limit"));
81
+ const endpointRemaining = finiteNumber(headerValue(headers, "x-ratelimit-endpoint-requests-remaining"));
82
+ const endpointResetTimestamp = finiteNumber(headerValue(headers, "x-ratelimit-endpoint-requests-reset"));
83
+ const hasComplexity = complexityCost !== undefined ||
84
+ complexityLimit !== undefined ||
85
+ complexityRemaining !== undefined ||
86
+ complexityResetTimestamp !== undefined;
87
+ const hasEndpoint = endpointName !== undefined ||
88
+ endpointLimit !== undefined ||
89
+ endpointRemaining !== undefined ||
90
+ endpointResetTimestamp !== undefined;
91
+ if (limit === undefined &&
92
+ remaining === undefined &&
93
+ resetAt === undefined &&
94
+ !hasComplexity &&
95
+ !hasEndpoint) {
96
+ return undefined;
97
+ }
98
+ return {
99
+ limit,
100
+ remaining,
101
+ resetAt,
102
+ observedRequests: 1,
103
+ minimumRemaining: remaining,
104
+ ...(hasComplexity
105
+ ? {
106
+ complexity: {
107
+ cost: complexityCost,
108
+ totalCost: complexityCost,
109
+ limit: complexityLimit,
110
+ remaining: complexityRemaining,
111
+ minimumRemaining: complexityRemaining,
112
+ resetAt: complexityResetTimestamp === undefined
113
+ ? undefined
114
+ : new Date(complexityResetTimestamp).toISOString(),
115
+ },
116
+ }
117
+ : {}),
118
+ ...(hasEndpoint
119
+ ? {
120
+ endpoints: {
121
+ [endpointName ?? "unknown"]: {
122
+ limit: endpointLimit,
123
+ remaining: endpointRemaining,
124
+ minimumRemaining: endpointRemaining,
125
+ resetAt: endpointResetTimestamp === undefined
126
+ ? undefined
127
+ : new Date(endpointResetTimestamp).toISOString(),
128
+ observedRequests: 1,
129
+ },
130
+ },
131
+ }
132
+ : {}),
133
+ };
134
+ }
135
+ /** True for both HTTP and GraphQL representations of Linear rate limiting. */
136
+ export function isRateLimitGraphQLError(error) {
137
+ const detail = errorDetail(error);
138
+ if (graphQLErrorHttpStatus(error) === 429 ||
139
+ graphQLErrorCode(error)?.toUpperCase() === "RATELIMITED" ||
140
+ detail.type?.toLowerCase() === "ratelimited") {
141
+ return true;
142
+ }
143
+ return /\b(?:429|rate[- ]?limit(?:ed| exceeded))\b/i.test(errorMessage(error));
144
+ }
145
+ /**
146
+ * True only for failures where retrying a known-idempotent request immediately
147
+ * is useful. This deliberately differs from published `errorDetail.retryable`:
148
+ * a rate limit is transient for an external caller with reset-aware backoff,
149
+ * but must never enter this CLI's 150 ms / 400 ms retry loop.
150
+ */
8
151
  export function isTransientGraphQLError(error) {
9
- const detail = error;
10
- const status = detail.response?.status ?? detail.response?.statusCode;
11
- if (status === 408 ||
12
- status === 429 ||
13
- (status !== undefined && status >= 500)) {
152
+ if (isRateLimitGraphQLError(error))
153
+ return false;
154
+ const status = graphQLErrorHttpStatus(error);
155
+ if (status === 408 || (status !== null && status >= 500)) {
14
156
  return true;
15
157
  }
16
- return /\b(?:408|429|500|502|503|504)\b|(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|fetch failed|network error|connection termination)/i.test(errorMessage(error));
158
+ return /\b(?:408|500|502|503|504)\b|(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|fetch failed|network error|connection termination)/i.test(errorMessage(error));
159
+ }
160
+ function isMutationOperation(query) {
161
+ const withoutLeadingComments = query.replace(/^(?:\s*#[^\n]*(?:\n|$))*/, "");
162
+ return /^\s*mutation\b/i.test(withoutLeadingComments);
163
+ }
164
+ function rateLimitError(error, query, now) {
165
+ const rateLimit = extractRateLimitInfo(error, now);
166
+ const details = [];
167
+ if (rateLimit?.remaining !== undefined && rateLimit.limit !== undefined) {
168
+ details.push(`${rateLimit.remaining}/${rateLimit.limit} requests remain in the current window`);
169
+ }
170
+ if (rateLimit?.resetAt) {
171
+ details.push(`the request limit resets at ${rateLimit.resetAt}`);
172
+ }
173
+ const suffix = details.length > 0 ? ` ${details.join("; ")}.` : "";
174
+ const ambiguity = isMutationOperation(query)
175
+ ? " This mutation may have been applied; verify its outcome before retrying."
176
+ : "";
177
+ return toLinearGraphQLError(error, `GraphQL request failed: Rate limit exceeded.${suffix}${ambiguity}`);
17
178
  }
18
179
  function buildLinearClient(auth) {
19
180
  const baseHeaders = { "public-file-urls-expire-in": "3600" };
@@ -29,10 +190,61 @@ function buildLinearClient(auth) {
29
190
  }
30
191
  return new LinearClient({ apiKey: auth.apiKey, headers: baseHeaders });
31
192
  }
193
+ async function persistRateLimitObservation(admission, info) {
194
+ recordRateLimitInfo(info);
195
+ try {
196
+ await admission.observe(info);
197
+ }
198
+ catch (error) {
199
+ // The request already completed. Never turn an observation write
200
+ // failure into a replayable command failure (especially for mutations).
201
+ const message = error instanceof Error ? error.message : String(error);
202
+ logger.error(`[rate-limit] could not persist quota observation: ${message}`);
203
+ }
204
+ }
205
+ /**
206
+ * Put SDK-backed LinearService calls through the same quota admission and
207
+ * response-header observer as custom GraphQLService calls. The SDK's public
208
+ * `request()` discards response headers, so this narrow transport adapter uses
209
+ * its own public-ish rawRequest escape hatch and returns only `data` to the
210
+ * generated SDK models, preserving their existing contract.
211
+ */
212
+ export function instrumentLinearClient(client, auth, options = {}) {
213
+ const transport = client
214
+ .client;
215
+ // Unit-test doubles and future SDK versions may not expose the escape hatch.
216
+ // Leave them untouched rather than manufacturing a partial transport.
217
+ if (!transport?.rawRequest || !transport.request)
218
+ return client;
219
+ const originalRawRequest = transport.rawRequest.bind(transport);
220
+ const admission = options.admission ?? createRateLimitAdmission(auth, { now: options.now });
221
+ const now = options.now ?? Date.now;
222
+ transport.request = async (document, variables, requestHeaders) => {
223
+ await admission.admit();
224
+ const query = typeof document === "string" ? document : print(document);
225
+ try {
226
+ const response = await originalRawRequest(query, variables, requestHeaders);
227
+ const rateLimit = extractRateLimitInfo(response, now);
228
+ if (rateLimit)
229
+ await persistRateLimitObservation(admission, rateLimit);
230
+ return response.data;
231
+ }
232
+ catch (error) {
233
+ const rateLimit = extractRateLimitInfo(error, now);
234
+ if (rateLimit)
235
+ await persistRateLimitObservation(admission, rateLimit);
236
+ throw error;
237
+ }
238
+ };
239
+ return client;
240
+ }
32
241
  export class GraphQLService {
33
242
  graphQLClient;
34
243
  retryDelaysMs;
35
244
  sleep;
245
+ now;
246
+ admission;
247
+ renewAccessToken;
36
248
  constructor(auth, options = {}) {
37
249
  const client = buildLinearClient(auth);
38
250
  // LinearClient stores a private graphql-request client — access via escape hatch
@@ -42,18 +254,43 @@ export class GraphQLService {
42
254
  this.sleep =
43
255
  options.sleep ??
44
256
  ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
257
+ this.now = options.now ?? Date.now;
258
+ this.admission =
259
+ options.admission ?? createRateLimitAdmission(auth, { now: this.now });
260
+ this.renewAccessToken = options.renewAccessToken;
261
+ }
262
+ async observeRateLimit(info) {
263
+ await persistRateLimitObservation(this.admission, info);
45
264
  }
46
265
  // Default type allows property access on raw GraphQL responses.
47
266
  // Callers can narrow with explicit type parameter: rawRequest<{ issues: { nodes: T[] } }>(...)
48
267
  async rawRequest(query, variables, options = {}) {
49
268
  let attempt = 0;
269
+ let renewedAfterUnauthorized = false;
50
270
  try {
51
271
  while (true) {
52
272
  try {
273
+ await this.admission.admit();
53
274
  const response = await this.graphQLClient.rawRequest(query, variables);
275
+ const rateLimit = extractRateLimitInfo(response, this.now);
276
+ if (rateLimit)
277
+ await this.observeRateLimit(rateLimit);
54
278
  return response.data;
55
279
  }
56
280
  catch (error) {
281
+ if (error instanceof RateLimitAdmissionRefusal)
282
+ throw error;
283
+ const rateLimit = extractRateLimitInfo(error, this.now);
284
+ if (rateLimit)
285
+ await this.observeRateLimit(rateLimit);
286
+ if (!renewedAfterUnauthorized &&
287
+ this.renewAccessToken &&
288
+ graphQLErrorHttpStatus(error) === 401) {
289
+ const token = await this.renewAccessToken();
290
+ this.graphQLClient.setHeader("authorization", `Bearer ${token}`);
291
+ renewedAfterUnauthorized = true;
292
+ continue;
293
+ }
57
294
  if (!options.retrySafeMutation ||
58
295
  attempt >= this.retryDelaysMs.length ||
59
296
  !isTransientGraphQLError(error)) {
@@ -65,19 +302,35 @@ export class GraphQLService {
65
302
  }
66
303
  }
67
304
  catch (error) {
68
- const err = error;
69
- if (err.response?.errors) {
70
- const graphQLError = err.response.errors[0];
71
- throw new Error(graphQLError.message || "GraphQL query failed");
305
+ if (error instanceof RateLimitAdmissionRefusal)
306
+ throw error;
307
+ if (isRateLimitGraphQLError(error)) {
308
+ throw rateLimitError(error, query, this.now);
72
309
  }
73
- throw new Error(`GraphQL request failed: ${err.message}`);
310
+ const err = error;
311
+ // The message text is byte-identical to what the plain `Error`
312
+ // carried before DEV-7987; only the classification is new, so
313
+ // callers matching on the message are untouched.
314
+ const message = err.response?.errors
315
+ ? err.response.errors[0]?.message || "GraphQL query failed"
316
+ : `GraphQL request failed: ${err.message}`;
317
+ // This is the ONLY place a Linear GraphQL failure leaves the
318
+ // service, so it is the only place that has to attach the
319
+ // classification. Any future branch that throws its own shaped
320
+ // message from here must go through `toLinearGraphQLError` too, or
321
+ // that failure silently loses its `errorDetail` on the envelope.
322
+ throw toLinearGraphQLError(error, message);
74
323
  }
75
324
  }
76
325
  }
77
326
  export async function createGraphQLService(options) {
78
327
  const auth = await getActiveAuth(options);
79
328
  if (auth.kind === "oauth") {
80
- return new GraphQLService({ oauthToken: auth.token });
329
+ const credential = { oauthToken: auth.token };
330
+ return new GraphQLService(credential, {
331
+ admission: createOAuthProfileRateLimitAdmission(credential, auth.oauth.clientId, auth.oauth.viewerId),
332
+ renewAccessToken: createClientCredentialsTokenRenewal(auth.oauth),
333
+ });
81
334
  }
82
335
  return new GraphQLService({ apiKey: auth.token });
83
336
  }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Structured classification for a failed Linear GraphQL request (DEV-7987).
3
+ *
4
+ * WHY THIS EXISTS. Every command routes its failures through `outputError`
5
+ * in `utils/output.ts`, which used to emit `{ error, activeProfile }` and
6
+ * nothing else. That flattens a rate limit, a gateway blip and a permanent
7
+ * schema error into one indistinguishable shape, so a machine caller
8
+ * shelling out to `el-linear` (a job runner deciding whether to retry, say)
9
+ * had to substring-match the message to guess. This module carries the two
10
+ * facts that were being thrown away — the HTTP status and the GraphQL
11
+ * `extensions.code` — plus the retryable verdict derived from them, so the
12
+ * envelope can state them.
13
+ *
14
+ * Kept in its own module, free of `@linear/sdk`, so `utils/output.ts` can
15
+ * read the classification without importing the GraphQL client.
16
+ */
17
+ /**
18
+ * GraphQL `extensions.code` values Linear returns for conditions that clear
19
+ * on their own. A caller may retry these regardless of the HTTP status —
20
+ * Linear answers a rate limit with `RATELIMITED` under more than one
21
+ * status, so the code is load-bearing on its own.
22
+ */
23
+ export declare const RETRYABLE_GRAPHQL_ERROR_CODES: readonly string[];
24
+ /**
25
+ * The classification block `el-linear` publishes as `errorDetail` on a
26
+ * failed command's JSON envelope. Status, code and retryability are always
27
+ * present; `null` means no such response signal. A known admission deadline
28
+ * is additive and optional.
29
+ */
30
+ export interface LinearGraphQLErrorDetail {
31
+ /** HTTP status of the Linear response, or `null` if it never arrived. */
32
+ httpStatus: number | null;
33
+ /** GraphQL `extensions.code` of the first error, or `null`. */
34
+ code: string | null;
35
+ /** Whether retrying the identical request could plausibly succeed. */
36
+ retryable: boolean;
37
+ /** Known quota reset or recovery-probe deadline, when admission refused locally. */
38
+ resetAt?: string;
39
+ }
40
+ /** HTTP status of a rejected request, or `null` if no response arrived. */
41
+ export declare function graphQLErrorHttpStatus(error: unknown): number | null;
42
+ /** First GraphQL error's `extensions.code`, or `null` when absent. */
43
+ export declare function graphQLErrorCode(error: unknown): string | null;
44
+ /** True when an HTTP status alone justifies a retry. */
45
+ export declare function isRetryableHttpStatus(status: number | null): boolean;
46
+ /** True when a GraphQL `extensions.code` alone justifies a retry. */
47
+ export declare function isRetryableGraphQLCode(code: string | null): boolean;
48
+ /**
49
+ * The retryable verdict from the authoritative response status and GraphQL code.
50
+ *
51
+ * "Retryable" here means *the failure is transient* — a caller that waits
52
+ * an appropriate interval could succeed. It is NOT the same question as
53
+ * `isTransientGraphQLError` in `graphql-service.ts`, which gates this CLI's
54
+ * own immediate 150 ms / 400 ms retry: a rate limit is transient (so
55
+ * `retryable: true`) but a terrible candidate for an immediate retry.
56
+ *
57
+ * The last arm is the one that is easy to get wrong: a rejection carrying
58
+ * NEITHER a status NOR a code never got an answer out of Linear at all —
59
+ * DNS, a refused connection, a dropped socket — and those are exactly the
60
+ * failures worth retrying. Every failure Linear itself produced arrives
61
+ * with a status (`LinearError.status` is set from the HTTP response), so
62
+ * this arm cannot swallow a permanent schema or permission error. Message
63
+ * text is deliberately excluded: a permanent response may legitimately
64
+ * mention a transient-looking number such as a validation limit of 500.
65
+ */
66
+ export declare function classifyGraphQLFailure(httpStatus: number | null, code: string | null): boolean;
67
+ /**
68
+ * A Linear GraphQL failure that carries its own classification.
69
+ *
70
+ * The `message` is unchanged from what the plain `Error` used to carry, so
71
+ * every existing consumer that matches on the message text keeps working;
72
+ * the classification is purely additive.
73
+ */
74
+ export declare class LinearGraphQLError extends Error {
75
+ readonly httpStatus: number | null;
76
+ readonly code: string | null;
77
+ readonly retryable: boolean;
78
+ readonly resetAt?: string;
79
+ constructor(message: string, detail: LinearGraphQLErrorDetail);
80
+ get detail(): LinearGraphQLErrorDetail;
81
+ }
82
+ /**
83
+ * Wrap a rejection from the Linear GraphQL client in a classified error,
84
+ * keeping `message` exactly as the caller composed it.
85
+ *
86
+ * This is the seam every throw out of `GraphQLService.rawRequest` must pass
87
+ * through. A branch that builds its own message and throws a bare `Error`
88
+ * instead — a rate-limit path with a friendlier wording, say — produces a
89
+ * failure with NO `errorDetail` on the envelope, which is precisely the
90
+ * blindness DEV-7987 exists to remove, and it fails silently.
91
+ */
92
+ export declare function toLinearGraphQLError(error: unknown, message: string): LinearGraphQLError;
93
+ /**
94
+ * Read the classification off an arbitrary thrown value, or `null` when it
95
+ * carries none. Structural rather than `instanceof` so a
96
+ * `LinearGraphQLError` that crossed a module-instance boundary (a vitest
97
+ * mock, a duplicated dependency copy) is still recognized — the envelope
98
+ * contract must not depend on prototype identity.
99
+ */
100
+ export declare function readGraphQLErrorDetail(error: unknown): LinearGraphQLErrorDetail | null;