Skip to content

Commit a967d2f

Browse files
authored
fix(polling): throw TimeoutError instead of bare Error on timeout (#503)
Replace the generic `new Error(timeoutMessage)` in `poll()` with a structured `TimeoutError` (extends `CliError`) that carries an optional `hint` field. This gives the CLI error handler richer context to display actionable suggestions when a polling operation times out. ## Changes - **`src/lib/errors.ts`** — Add `TimeoutError` class with optional `hint` and `format()` override - **`src/lib/polling.ts`** — Throw `TimeoutError` instead of bare `Error`; add `timeoutHint` option to `PollOptions` - **`src/lib/sentry-urls.ts`** — Add `buildIssueUrl()` helper (SaaS + self-hosted) - **`src/commands/issue/utils.ts`** — `pollAutofixState` passes a hint with the issue URL and retry command - **`test/lib/polling.property.test.ts`** — Assert `TimeoutError` type and verify hint propagation ## Context Addresses [CLI-J0](https://sentry.sentry.io/issues/7350074690/) — a user hit the 6-minute Seer analysis timeout via `sentry issue explain`. The timeout itself is expected (the backend can be slow), but the bare `Error` gave no guidance on what to do next. Now the user sees: ``` Error: Operation timed out after 6 minutes. Try again or check the issue in Sentry web UI. The analysis may still complete in the background. View in Sentry: https://my-org.sentry.io/issues/12345/ Or retry: sentry issue explain 12345 ```
1 parent a266720 commit a967d2f

File tree

6 files changed

+98
-9
lines changed

6 files changed

+98
-9
lines changed

src/commands/issue/plan.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,9 @@ export const planCommand = buildCommand({
247247
json: flags.json,
248248
timeoutMessage:
249249
"Plan creation timed out after 6 minutes. Try again or check the issue in Sentry web UI.",
250+
timeoutHint:
251+
"The plan may still be generated in the background.\n" +
252+
` Or retry: sentry issue plan ${issueArg}`,
250253
});
251254

252255
// Handle errors

src/commands/issue/utils.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
resolveOrgAndProject,
4040
} from "../../lib/resolve-target.js";
4141
import { parseSentryUrl } from "../../lib/sentry-url-parser.js";
42+
import { buildIssueUrl } from "../../lib/sentry-urls.js";
4243
import { isAllDigits } from "../../lib/utils.js";
4344
import type { SentryIssue } from "../../types/index.js";
4445
import { type AutofixState, isTerminalStatus } from "../../types/seer.js";
@@ -624,6 +625,9 @@ type PollAutofixOptions = {
624625
timeoutMs?: number;
625626
/** Custom timeout error message */
626627
timeoutMessage?: string;
628+
/** Actionable hint appended to the TimeoutError (e.g., "Run the command again…").
629+
* When omitted, defaults to a hint with the Sentry issue URL. */
630+
timeoutHint?: string;
627631
/** Stop polling when status is WAITING_FOR_USER_RESPONSE (default: false) */
628632
stopOnWaitingForUser?: boolean;
629633
};
@@ -729,9 +733,17 @@ export async function pollAutofixState(
729733
pollIntervalMs,
730734
timeoutMs = DEFAULT_TIMEOUT_MS,
731735
timeoutMessage = "Operation timed out after 6 minutes. Try again or check the issue in Sentry web UI.",
736+
timeoutHint,
732737
stopOnWaitingForUser = false,
733738
} = options;
734739

740+
const issueUrl = buildIssueUrl(orgSlug, issueId);
741+
const hint =
742+
timeoutHint ??
743+
"The analysis may still complete in the background.\n" +
744+
` View in Sentry: ${issueUrl}\n` +
745+
` Or retry: sentry issue explain ${issueId}`;
746+
735747
return await poll<AutofixState>({
736748
fetchState: () => getAutofixState(orgSlug, issueId),
737749
shouldStop: (state) => shouldStopPolling(state, stopOnWaitingForUser),
@@ -740,6 +752,7 @@ export async function pollAutofixState(
740752
pollIntervalMs,
741753
timeoutMs,
742754
timeoutMessage,
755+
timeoutHint: hint,
743756
initialMessage: "Waiting for analysis to start...",
744757
});
745758
}

src/lib/errors.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,33 @@ export class SeerError extends CliError {
433433
}
434434
}
435435

436+
/**
437+
* Timeout errors for long-running polling operations.
438+
*
439+
* Use when a polling loop exceeds its time budget. Provides structured
440+
* hints so the user knows the operation may still complete in the background.
441+
*
442+
* @param message - What timed out (e.g., "Operation timed out after 6 minutes.")
443+
* @param hint - Actionable suggestion (e.g., "Run the command again — the analysis may finish in the background.")
444+
*/
445+
export class TimeoutError extends CliError {
446+
readonly hint?: string;
447+
448+
constructor(message: string, hint?: string) {
449+
super(message);
450+
this.name = "TimeoutError";
451+
this.hint = hint;
452+
}
453+
454+
override format(): string {
455+
let msg = this.message;
456+
if (this.hint) {
457+
msg += `\n\n${this.hint}`;
458+
}
459+
return msg;
460+
}
461+
}
462+
436463
// Error Utilities
437464

438465
/**

src/lib/polling.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* Used by commands that need to wait for async operations to complete.
66
*/
77

8+
import { TimeoutError } from "./errors.js";
89
import {
910
formatProgressLine,
1011
truncateProgressMessage,
@@ -37,6 +38,8 @@ export type PollOptions<T> = {
3738
timeoutMs?: number;
3839
/** Custom timeout message */
3940
timeoutMessage?: string;
41+
/** Actionable hint appended to the TimeoutError (e.g., "Run the command again…") */
42+
timeoutHint?: string;
4043
/** Initial progress message */
4144
initialMessage?: string;
4245
};
@@ -51,7 +54,7 @@ export type PollOptions<T> = {
5154
* @typeParam T - The type of state being polled
5255
* @param options - Polling configuration
5356
* @returns The final state when shouldStop returns true
54-
* @throws {Error} When timeout is reached before shouldStop returns true
57+
* @throws {TimeoutError} When timeout is reached before shouldStop returns true
5558
*
5659
* @example
5760
* ```typescript
@@ -74,6 +77,7 @@ export async function poll<T>(options: PollOptions<T>): Promise<T> {
7477
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
7578
timeoutMs = DEFAULT_TIMEOUT_MS,
7679
timeoutMessage = "Operation timed out after 6 minutes. Try again or check the Sentry web UI.",
80+
timeoutHint,
7781
initialMessage = "Waiting for operation to start...",
7882
} = options;
7983

@@ -98,7 +102,7 @@ export async function poll<T>(options: PollOptions<T>): Promise<T> {
98102
await Bun.sleep(pollIntervalMs);
99103
}
100104

101-
throw new Error(timeoutMessage);
105+
throw new TimeoutError(timeoutMessage, timeoutHint);
102106
} finally {
103107
spinner?.stop();
104108
if (!json) {

src/lib/sentry-urls.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,20 @@ export function buildProjectUrl(orgSlug: string, projectSlug: string): string {
8888
return `${getSentryBaseUrl()}/settings/${orgSlug}/projects/${projectSlug}/`;
8989
}
9090

91+
/**
92+
* Build URL to view an issue in Sentry.
93+
*
94+
* @param orgSlug - Organization slug
95+
* @param issueId - Numeric issue ID
96+
* @returns Full URL to the issue detail page
97+
*/
98+
export function buildIssueUrl(orgSlug: string, issueId: string): string {
99+
if (isSaaS()) {
100+
return `${getOrgBaseUrl(orgSlug)}/issues/${issueId}/`;
101+
}
102+
return `${getSentryBaseUrl()}/organizations/${orgSlug}/issues/${issueId}/`;
103+
}
104+
91105
/**
92106
* Build URL to search for an event in Sentry.
93107
* Uses the issues search with event.id filter.

test/lib/polling.property.test.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
integer,
1414
nat,
1515
} from "fast-check";
16+
import { TimeoutError } from "../../src/lib/errors.js";
1617
import { poll } from "../../src/lib/polling.js";
1718
import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js";
1819

@@ -70,28 +71,55 @@ describe("poll properties", () => {
7071
);
7172
});
7273

73-
test("throws timeout error when shouldStop never returns true", async () => {
74+
test("throws TimeoutError when shouldStop never returns true", async () => {
7475
await fcAssert(
7576
asyncProperty(nat(50), async (stateValue) => {
7677
const timeoutMs = 50; // Very short timeout for testing
7778
const customMessage = `Custom timeout: ${stateValue}`;
7879

79-
await expect(
80-
poll({
80+
try {
81+
await poll({
8182
fetchState: async () => ({ value: stateValue }),
8283
shouldStop: () => false, // Never stop
8384
getProgressMessage: () => "Testing...",
8485
json: true,
8586
pollIntervalMs: 10,
8687
timeoutMs,
8788
timeoutMessage: customMessage,
88-
})
89-
).rejects.toThrow(customMessage);
89+
});
90+
// Should not reach here
91+
expect.unreachable("Expected TimeoutError to be thrown");
92+
} catch (error) {
93+
expect(error).toBeInstanceOf(TimeoutError);
94+
expect((error as TimeoutError).message).toBe(customMessage);
95+
}
9096
}),
9197
{ numRuns: Math.min(DEFAULT_NUM_RUNS, 20) } // Fewer runs since timeout tests are slow
9298
);
9399
});
94100

101+
test("TimeoutError includes hint when provided", async () => {
102+
const customHint = "Try running the command again.";
103+
104+
try {
105+
await poll({
106+
fetchState: async () => ({ value: 1 }),
107+
shouldStop: () => false,
108+
getProgressMessage: () => "Testing...",
109+
json: true,
110+
pollIntervalMs: 10,
111+
timeoutMs: 50,
112+
timeoutHint: customHint,
113+
});
114+
expect.unreachable("Expected TimeoutError to be thrown");
115+
} catch (error) {
116+
expect(error).toBeInstanceOf(TimeoutError);
117+
const te = error as TimeoutError;
118+
expect(te.hint).toBe(customHint);
119+
expect(te.format()).toContain(customHint);
120+
}
121+
});
122+
95123
test("fetchState call count is bounded by timeout/interval", async () => {
96124
await fcAssert(
97125
asyncProperty(
@@ -200,7 +228,7 @@ describe("poll properties", () => {
200228

201229
describe("poll edge cases", () => {
202230
test("handles immediate timeout (timeoutMs = 0)", async () => {
203-
// With 0 timeout, should throw immediately or after first fetch
231+
// With 0 timeout, should throw TimeoutError immediately or after first fetch
204232
await expect(
205233
poll({
206234
fetchState: async () => ({ value: 1 }),
@@ -210,7 +238,7 @@ describe("poll edge cases", () => {
210238
pollIntervalMs: 10,
211239
timeoutMs: 0,
212240
})
213-
).rejects.toThrow();
241+
).rejects.toBeInstanceOf(TimeoutError);
214242
});
215243

216244
test("handles fetchState throwing errors", async () => {

0 commit comments

Comments
 (0)