Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"dependencies": {
"@linear/sdk": "82.1.0",
"commander": "14.0.3",
"graphql": "16.12.0",
"node-emoji": "2.2.0"
},
"devDependencies": {
Expand Down
165 changes: 136 additions & 29 deletions src/client/graphql-client.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,119 @@
import { LinearClient } from "@linear/sdk";
import { type DocumentNode, print } from "graphql";
import { AuthenticationError, isAuthError } from "../common/errors.js";
import { withRetry } from "../common/retry.js";

/** Default timeout for GraphQL API requests (30 seconds) */
const REQUEST_TIMEOUT_MS = 30_000;
const LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql";

interface GraphQLErrorResponse {
response?: {
errors?: Array<{ message: string }>;
};
interface GraphQLResponseError {
message: string;
}

interface GraphQLResponseBody {
data?: unknown;
errors?: GraphQLResponseError[];
message?: string;
}

class GraphQLTransportError extends Error {
readonly response: {
status?: number;
errors?: GraphQLResponseError[];
};

constructor(
message: string,
response: { status?: number; errors?: GraphQLResponseError[] },
) {
super(message);
this.name = "GraphQLTransportError";
this.response = response;
}
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

function toGraphQLErrors(value: unknown): GraphQLResponseError[] | undefined {
if (!Array.isArray(value)) return undefined;

const errors = value.flatMap((entry): GraphQLResponseError[] => {
if (!isRecord(entry) || typeof entry.message !== "string") return [];
return [{ message: entry.message }];
});

return errors.length > 0 ? errors : undefined;
}

async function parseJsonResponse(
response: Response,
): Promise<GraphQLResponseBody | undefined> {
const text = await response.text();
if (text.trim() === "") return undefined;

const parsed: unknown = JSON.parse(text);
if (!isRecord(parsed)) return undefined;

return {
data: parsed.data,
errors: toGraphQLErrors(parsed.errors),
message: typeof parsed.message === "string" ? parsed.message : undefined,
};
}

async function parseResponseBody(
response: Response,
): Promise<GraphQLResponseBody | undefined> {
try {
return await parseJsonResponse(response);
} catch (_error: unknown) {
if (response.ok) throw new Error("Invalid JSON response");
return undefined;
Comment on lines +71 to +73
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve abort errors in successful response parsing

If the timeout aborts after fetch() resolves but during response.text(), parseJsonResponse() will reject with an abort error; this catch block rewrites that into Invalid JSON response whenever response.ok is true. That masks timeouts from isAbortAfterTimeout() in request(), so these slow/stalled 200 responses are reported as JSON errors and are no longer retried as timeout failures.

Useful? React with 👍 / 👎.

Comment on lines +71 to +73
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve abort failures when parsing error responses

Do not swallow all parsing exceptions for non-2xx responses here: if the timeout abort fires after fetch() returns headers but during response.text(), this branch returns undefined and the caller rethrows an HTTP-status GraphQLTransportError instead of Request timed out. In that scenario, stalled 4xx responses are misreported as regular HTTP failures and skip timeout-based retry behavior.

Useful? React with 👍 / 👎.

}
}

function httpErrorMessage(
response: Response,
body: GraphQLResponseBody | undefined,
): string {
return (
body?.errors?.[0]?.message ??
body?.message ??
`HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`
);
}

function isAbortAfterTimeout(signal: AbortSignal, error: unknown): boolean {
if (!signal.aborted) return false;
if (!(error instanceof Error)) return true;

const message = error.message.toLowerCase();
return error.name === "AbortError" || message.includes("aborted");
}

function normalizeFetchRejection(signal: AbortSignal, error: unknown): never {
if (signal.aborted) throw error;
if (error instanceof Error) {
throw new Error(`Network error: ${error.message}`);
}
throw new Error(`Network error: ${String(error)}`);
}

function graphQLErrorMessage(error: unknown): string {
if (!isRecord(error) || !isRecord(error.response)) return "";

return toGraphQLErrors(error.response.errors)?.[0]?.message ?? "";
}

export class GraphQLClient {
private readonly apiToken: string;

constructor(apiToken: string) {
this.apiToken = apiToken;
}

private createRawClient(
signal?: AbortSignal,
): InstanceType<typeof LinearClient>["client"] {
const linearClient = new LinearClient({
apiKey: this.apiToken,
signal,
headers: {
// Request 1-hour signed URLs for file downloads (see file-service.ts)
"public-file-urls-expire-in": "3600",
},
});
return linearClient.client;
}

async request<TResult>(
document: DocumentNode,
variables?: Record<string, unknown>,
Expand All @@ -46,15 +126,43 @@ export class GraphQLClient {
}, REQUEST_TIMEOUT_MS);

try {
return await this.createRawClient(
timeoutController.signal,
).rawRequest(print(document), variables);
const fetchResponse = await fetch(LINEAR_GRAPHQL_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: this.apiToken,
// Request 1-hour signed URLs for file downloads (see file-service.ts)
"public-file-urls-expire-in": "3600",
},
body: JSON.stringify({ query: print(document), variables }),
signal: timeoutController.signal,
}).catch((error: unknown) =>
normalizeFetchRejection(timeoutController.signal, error),
);

const body = await parseResponseBody(fetchResponse);

if (!fetchResponse.ok) {
throw new GraphQLTransportError(
httpErrorMessage(fetchResponse, body),
{
status: fetchResponse.status,
errors: body?.errors,
},
);
}

if (body?.errors?.[0]) {
throw new GraphQLTransportError(body.errors[0].message, {
errors: body.errors,
});
}

if (!body) throw new Error("Invalid JSON response");

return { data: body.data };
} catch (error: unknown) {
if (
timeoutController.signal.aborted &&
error instanceof Error &&
error.message.toLowerCase().includes("aborted")
) {
if (isAbortAfterTimeout(timeoutController.signal, error)) {
throw new Error("Request timed out");
}
throw error;
Expand All @@ -64,8 +172,7 @@ export class GraphQLClient {
});
return response.data as TResult;
} catch (error: unknown) {
const gqlError = error as GraphQLErrorResponse;
const errorMessage = gqlError.response?.errors?.[0]?.message ?? "";
const errorMessage = graphQLErrorMessage(error);

if (isAuthError(new Error(errorMessage))) {
throw new AuthenticationError(errorMessage || undefined);
Expand Down
Loading
Loading