@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.
- package/README.md +159 -0
- package/claude-skills/linear-operations/SKILL.md +17 -0
- package/dist/auth/oauth-storage.d.ts +5 -3
- package/dist/auth/oauth-token.d.ts +7 -0
- package/dist/auth/oauth-token.js +13 -0
- package/dist/auth/token-resolver.d.ts +2 -0
- package/dist/auth/token-resolver.js +25 -10
- package/dist/commands/init/index.js +17 -1
- package/dist/commands/init/oauth.d.ts +9 -1
- package/dist/commands/init/oauth.js +62 -3
- package/dist/commands/issues.js +244 -1
- package/dist/config/config.d.ts +30 -0
- package/dist/config/config.js +3 -0
- package/dist/config/consent-receipt.d.ts +76 -0
- package/dist/config/consent-receipt.js +219 -0
- package/dist/config/label-advisor.d.ts +53 -0
- package/dist/config/label-advisor.js +245 -0
- package/dist/queries/catalog-types.d.ts +129 -0
- package/dist/queries/catalog-types.js +1 -0
- package/dist/queries/catalog.d.ts +20 -0
- package/dist/queries/catalog.js +140 -0
- package/dist/utils/disk-cache.js +48 -14
- package/dist/utils/graphql-service.d.ts +42 -1
- package/dist/utils/graphql-service.js +267 -14
- package/dist/utils/linear-graphql-error.d.ts +100 -0
- package/dist/utils/linear-graphql-error.js +182 -0
- package/dist/utils/linear-service.d.ts +19 -2
- package/dist/utils/linear-service.js +194 -214
- package/dist/utils/output.d.ts +39 -0
- package/dist/utils/output.js +152 -1
- package/dist/utils/rate-limit-admission.d.ts +33 -0
- package/dist/utils/rate-limit-admission.js +240 -0
- package/package.json +79 -79
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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|
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
throw
|
|
305
|
+
if (error instanceof RateLimitAdmissionRefusal)
|
|
306
|
+
throw error;
|
|
307
|
+
if (isRateLimitGraphQLError(error)) {
|
|
308
|
+
throw rateLimitError(error, query, this.now);
|
|
72
309
|
}
|
|
73
|
-
|
|
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
|
-
|
|
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;
|