Skip to content

Commit 2733dbe

Browse files
authored
Improve Linear API timeout errors (#15)
* fix: improve Linear API timeout errors * fix: address PR timeout review feedback
1 parent 90f746f commit 2733dbe

8 files changed

Lines changed: 460 additions & 33 deletions

File tree

packages/cli/src/bin/linear.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,18 @@ import { createProgram } from "../index.js";
33

44
const program = createProgram();
55

6-
program.parseAsync(process.argv).catch((error: unknown) => {
7-
const message = error instanceof Error ? error.message : "Unknown CLI error";
8-
console.error(message);
9-
process.exitCode = 1;
10-
});
6+
const drainAndExit = (code: number): void => {
7+
Promise.all([
8+
new Promise<void>((resolve) => process.stdout.write("", () => resolve())),
9+
new Promise<void>((resolve) => process.stderr.write("", () => resolve())),
10+
]).finally(() => process.exit(code));
11+
};
12+
13+
program
14+
.parseAsync(process.argv)
15+
.then(() => drainAndExit(typeof process.exitCode === "number" ? process.exitCode : 0))
16+
.catch((error: unknown) => {
17+
const message = error instanceof Error ? error.message : "Unknown CLI error";
18+
console.error(message);
19+
drainAndExit(1);
20+
});

packages/cli/src/index.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -316,9 +316,9 @@ export function createProgram(authManager = new AuthManager()): Command {
316316
.option("--sort <field>", "Sort by a field, prefix with - for descending")
317317
.option("--view <preset>", "Human output preset: table | detail | dense")
318318
.option("--all", "Drain all pages before filtering")
319-
.option(
320-
"--fields <list>",
321-
"Comma-separated field selection (applies to JSON and human output)",
319+
.option("--fields <list>", "Comma-separated field selection (applies to JSON and human output)")
320+
.option("--timeout <seconds>", "Per-request network timeout in seconds (default 30)", (value) =>
321+
Number.parseInt(value, 10),
322322
);
323323

324324
const authCommand = program.command("auth").description("Authentication commands");
@@ -490,7 +490,10 @@ export function createProgram(authManager = new AuthManager()): Command {
490490

491491
const openSessionForCommand = async (cmd: Command) => {
492492
const globals = getGlobalOptions(cmd);
493-
return authManager.openSession({ profile: globals.profile });
493+
return authManager.openSession({
494+
profile: globals.profile,
495+
timeoutMs: globals.timeoutMs,
496+
});
494497
};
495498

496499
const sessionGateway = async (cmd: Command) => (await openSessionForCommand(cmd)).gateway;

packages/cli/src/runtime/options.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ export interface GlobalOptions {
2121
readonly view?: "table" | "detail" | "dense";
2222
readonly all?: boolean;
2323
readonly fields?: readonly string[];
24+
readonly timeoutMs: number;
2425
}
2526

27+
const DEFAULT_TIMEOUT_SECONDS = 30;
28+
2629
export function getGlobalOptions(command: Command): GlobalOptions {
2730
const rawValue = command.optsWithGlobals();
2831
const raw = isRecord(rawValue) ? rawValue : {};
@@ -31,6 +34,12 @@ export function getGlobalOptions(command: Command): GlobalOptions {
3134
.map((value) => value.trim())
3235
.filter((value) => value.length > 0);
3336

37+
const timeoutSeconds = readNumber(raw.timeout);
38+
const timeoutMs =
39+
timeoutSeconds !== undefined && timeoutSeconds > 0
40+
? timeoutSeconds * 1000
41+
: DEFAULT_TIMEOUT_SECONDS * 1000;
42+
3443
return {
3544
json: readBoolean(raw.json),
3645
quiet: readBoolean(raw.quiet),
@@ -51,5 +60,6 @@ export function getGlobalOptions(command: Command): GlobalOptions {
5160
...(readString(raw.view) ? { view: readString(raw.view) as "table" | "detail" | "dense" } : {}),
5261
...(readBoolean(raw.all) ? { all: true } : {}),
5362
...(fields && fields.length > 0 ? { fields } : {}),
63+
timeoutMs,
5464
};
5565
}

packages/cli/tests/options.test.ts

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,35 @@ import { Command } from "commander";
22
import { describe, expect, test } from "vitest";
33
import { getGlobalOptions } from "../src/runtime/options.js";
44

5+
function buildProgram(): Command {
6+
const program = new Command();
7+
program
8+
.option("--json")
9+
.option("--profile <name>")
10+
.option("--team <key>")
11+
.option("--limit <n>", (value) => Number.parseInt(value, 10))
12+
.option("--cursor <cursor>")
13+
.option("--quiet")
14+
.option("--mine")
15+
.option("--project <id>")
16+
.option("--cycle <id>")
17+
.option("--state <name>")
18+
.option("--assignee <name>")
19+
.option("--label <name>")
20+
.option("--priority <value>")
21+
.option("--status <name>")
22+
.option("--filter <expr>")
23+
.option("--sort <field>")
24+
.option("--view <preset>")
25+
.option("--all")
26+
.option("--fields <list>")
27+
.option("--timeout <seconds>", "Per-request timeout", (value) => Number.parseInt(value, 10));
28+
return program;
29+
}
30+
531
describe("getGlobalOptions", () => {
632
test("parses shared v2 query and presentation options", () => {
7-
const program = new Command();
8-
program
9-
.option("--json")
10-
.option("--profile <name>")
11-
.option("--team <key>")
12-
.option("--limit <n>", (value) => Number.parseInt(value, 10))
13-
.option("--cursor <cursor>")
14-
.option("--quiet")
15-
.option("--mine")
16-
.option("--project <id>")
17-
.option("--cycle <id>")
18-
.option("--state <name>")
19-
.option("--assignee <name>")
20-
.option("--label <name>")
21-
.option("--priority <value>")
22-
.option("--status <name>")
23-
.option("--filter <expr>")
24-
.option("--sort <field>")
25-
.option("--view <preset>")
26-
.option("--all")
27-
.option("--fields <list>");
33+
const program = buildProgram();
2834

2935
program.parse([
3036
"node",
@@ -80,6 +86,21 @@ describe("getGlobalOptions", () => {
8086
view: "detail",
8187
all: true,
8288
fields: ["identifier", "title", "assigneeName"],
89+
timeoutMs: 30_000,
8390
});
8491
});
92+
93+
test("converts --timeout seconds to timeoutMs", () => {
94+
const program = buildProgram();
95+
program.parse(["node", "linear", "--timeout", "5"]);
96+
97+
expect(getGlobalOptions(program).timeoutMs).toBe(5_000);
98+
});
99+
100+
test("defaults timeoutMs to 30s when --timeout absent", () => {
101+
const program = buildProgram();
102+
program.parse(["node", "linear"]);
103+
104+
expect(getGlobalOptions(program).timeoutMs).toBe(30_000);
105+
});
85106
});

packages/linear-core/src/auth/oauth.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,14 @@ interface TokenExchangeInput {
2929
readonly code: string;
3030
readonly redirectUri: string;
3131
readonly codeVerifier: string;
32+
readonly signal?: AbortSignal;
3233
}
3334

3435
interface TokenRefreshInput {
3536
readonly clientId: string;
3637
readonly tokenUrl: string;
3738
readonly refreshToken: string;
39+
readonly signal?: AbortSignal;
3840
}
3941

4042
const tokenResponseSchema = z.object({
@@ -172,6 +174,7 @@ export async function exchangeAuthorizationCode(
172174
"content-type": "application/x-www-form-urlencoded",
173175
},
174176
body,
177+
signal: input.signal,
175178
});
176179

177180
return parseTokenResponse(response);
@@ -193,6 +196,7 @@ export async function refreshOAuthToken(
193196
"content-type": "application/x-www-form-urlencoded",
194197
},
195198
body,
199+
signal: input.signal,
196200
});
197201

198202
const token = await parseTokenResponse(response);

packages/linear-core/src/auth/session.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,20 @@ export interface AuthorizationCodeLoginInput {
4444
readonly code: string;
4545
readonly redirectUri: string;
4646
readonly codeVerifier: string;
47+
readonly signal?: AbortSignal;
4748
}
4849

4950
export interface RefreshTokenInput {
5051
readonly profile: string;
5152
readonly clientId: string;
5253
readonly tokenUrl: string;
54+
readonly signal?: AbortSignal;
55+
}
56+
57+
export interface OpenSessionOptions {
58+
readonly profile?: string;
59+
readonly timeoutMs?: number;
60+
readonly signal?: AbortSignal;
5361
}
5462

5563
export interface ActiveSession {
@@ -59,6 +67,26 @@ export interface ActiveSession {
5967
readonly credentials: StoredCredentials;
6068
}
6169

70+
function resolveRequestSignal(options?: OpenSessionOptions): AbortSignal | undefined {
71+
if (!options) {
72+
return undefined;
73+
}
74+
const signals: AbortSignal[] = [];
75+
if (options.signal) {
76+
signals.push(options.signal);
77+
}
78+
if (typeof options.timeoutMs === "number" && options.timeoutMs > 0) {
79+
signals.push(AbortSignal.timeout(options.timeoutMs));
80+
}
81+
if (signals.length === 0) {
82+
return undefined;
83+
}
84+
if (signals.length === 1) {
85+
return signals[0];
86+
}
87+
return AbortSignal.any(signals);
88+
}
89+
6290
function toStoredCredentials(token: OAuthToken): StoredCredentials {
6391
return {
6492
accessToken: token.accessToken,
@@ -138,6 +166,7 @@ export class AuthManager {
138166
code: input.code,
139167
redirectUri: input.redirectUri,
140168
codeVerifier: input.codeVerifier,
169+
signal: input.signal,
141170
});
142171

143172
await this.loginWithToken({
@@ -163,6 +192,7 @@ export class AuthManager {
163192
clientId: input.clientId,
164193
tokenUrl: input.tokenUrl,
165194
refreshToken: existing.refreshToken,
195+
signal: input.signal,
166196
});
167197

168198
await store.set(input.profile, {
@@ -210,13 +240,14 @@ export class AuthManager {
210240
};
211241
}
212242

213-
public async openSession(options?: { readonly profile?: string }): Promise<ActiveSession> {
243+
public async openSession(options?: OpenSessionOptions): Promise<ActiveSession> {
214244
const config = await this.configStore.load();
215245
const selectedProfile = options?.profile ?? config.defaultProfile;
216246
const selected = config.profiles[selectedProfile];
217247
const store = await this.credentialsStore();
218248
const credentials = await store.get(selectedProfile);
219249
const oauthConfig = selected?.oauth;
250+
const requestSignal = resolveRequestSignal(options);
220251

221252
if (credentials?.accessToken) {
222253
let token: OAuthToken = {
@@ -230,6 +261,7 @@ export class AuthManager {
230261
clientId: oauthConfig.clientId,
231262
tokenUrl: oauthConfig.tokenUrl,
232263
refreshToken: token.refreshToken,
264+
signal: requestSignal,
233265
});
234266
token = refreshed;
235267
await store.set(selectedProfile, {
@@ -240,6 +272,7 @@ export class AuthManager {
240272

241273
const client = new LinearClient({
242274
accessToken: token.accessToken,
275+
...(requestSignal ? { signal: requestSignal } : {}),
243276
});
244277

245278
return {
@@ -255,7 +288,10 @@ export class AuthManager {
255288
}
256289

257290
if (credentials?.apiKey) {
258-
const client = new LinearClient({ apiKey: credentials.apiKey });
291+
const client = new LinearClient({
292+
apiKey: credentials.apiKey,
293+
...(requestSignal ? { signal: requestSignal } : {}),
294+
});
259295
return {
260296
profile: selectedProfile,
261297
client,
@@ -268,7 +304,10 @@ export class AuthManager {
268304

269305
const envApiKey = process.env.LINEAR_API_KEY;
270306
if (envApiKey) {
271-
const client = new LinearClient({ apiKey: envApiKey });
307+
const client = new LinearClient({
308+
apiKey: envApiKey,
309+
...(requestSignal ? { signal: requestSignal } : {}),
310+
});
272311
return {
273312
profile: selectedProfile,
274313
client,

0 commit comments

Comments
 (0)