Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 8 additions & 0 deletions src/commands/issue/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
resolveOrgAndProject,
} from "../../lib/resolve-target.js";
import { parseSentryUrl } from "../../lib/sentry-url-parser.js";
import { buildIssueUrl } from "../../lib/sentry-urls.js";
import { isAllDigits } from "../../lib/utils.js";
import type { SentryIssue } from "../../types/index.js";
import { type AutofixState, isTerminalStatus } from "../../types/seer.js";
Expand Down Expand Up @@ -730,6 +731,12 @@ export async function pollAutofixState(
stopOnWaitingForUser = false,
} = options;

const issueUrl = buildIssueUrl(orgSlug, issueId);
const timeoutHint =
"The analysis may still complete in the background.\n" +
` View in Sentry: ${issueUrl}\n` +
` Or retry: sentry issue explain ${issueId}`;

return await poll<AutofixState>({
fetchState: () => getAutofixState(orgSlug, issueId),
shouldStop: (state) => shouldStopPolling(state, stopOnWaitingForUser),
Expand All @@ -738,6 +745,7 @@ export async function pollAutofixState(
pollIntervalMs,
timeoutMs,
timeoutMessage,
timeoutHint,
initialMessage: "Waiting for analysis to start...",
});
}
27 changes: 27 additions & 0 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,33 @@ export class SeerError extends CliError {
}
}

/**
* Timeout errors for long-running polling operations.
*
* Use when a polling loop exceeds its time budget. Provides structured
* hints so the user knows the operation may still complete in the background.
*
* @param message - What timed out (e.g., "Operation timed out after 6 minutes.")
* @param hint - Actionable suggestion (e.g., "Run the command again — the analysis may finish in the background.")
*/
export class TimeoutError extends CliError {
readonly hint?: string;

constructor(message: string, hint?: string) {
super(message);
this.name = "TimeoutError";
this.hint = hint;
}

override format(): string {
let msg = this.message;
if (this.hint) {
msg += `\n\n${this.hint}`;
}
return msg;
}
}

// Error Utilities

/**
Expand Down
8 changes: 6 additions & 2 deletions src/lib/polling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* Used by commands that need to wait for async operations to complete.
*/

import { TimeoutError } from "./errors.js";
import {
formatProgressLine,
truncateProgressMessage,
Expand Down Expand Up @@ -37,6 +38,8 @@ export type PollOptions<T> = {
timeoutMs?: number;
/** Custom timeout message */
timeoutMessage?: string;
/** Actionable hint appended to the TimeoutError (e.g., "Run the command again…") */
timeoutHint?: string;
/** Initial progress message */
initialMessage?: string;
};
Expand All @@ -51,7 +54,7 @@ export type PollOptions<T> = {
* @typeParam T - The type of state being polled
* @param options - Polling configuration
* @returns The final state when shouldStop returns true
* @throws {Error} When timeout is reached before shouldStop returns true
* @throws {TimeoutError} When timeout is reached before shouldStop returns true
*
* @example
* ```typescript
Expand All @@ -74,6 +77,7 @@ export async function poll<T>(options: PollOptions<T>): Promise<T> {
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
timeoutMs = DEFAULT_TIMEOUT_MS,
timeoutMessage = "Operation timed out after 6 minutes. Try again or check the Sentry web UI.",
timeoutHint,
initialMessage = "Waiting for operation to start...",
} = options;

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

throw new Error(timeoutMessage);
throw new TimeoutError(timeoutMessage, timeoutHint);
} finally {
spinner?.stop();
if (!json) {
Expand Down
14 changes: 14 additions & 0 deletions src/lib/sentry-urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,20 @@ export function buildProjectUrl(orgSlug: string, projectSlug: string): string {
return `${getSentryBaseUrl()}/settings/${orgSlug}/projects/${projectSlug}/`;
}

/**
* Build URL to view an issue in Sentry.
*
* @param orgSlug - Organization slug
* @param issueId - Numeric issue ID
* @returns Full URL to the issue detail page
*/
export function buildIssueUrl(orgSlug: string, issueId: string): string {
if (isSaaS()) {
return `${getOrgBaseUrl(orgSlug)}/issues/${issueId}/`;
}
return `${getSentryBaseUrl()}/organizations/${orgSlug}/issues/${issueId}/`;
}

/**
* Build URL to search for an event in Sentry.
* Uses the issues search with event.id filter.
Expand Down
42 changes: 35 additions & 7 deletions test/lib/polling.property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
integer,
nat,
} from "fast-check";
import { TimeoutError } from "../../src/lib/errors.js";
import { poll } from "../../src/lib/polling.js";
import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js";

Expand Down Expand Up @@ -70,28 +71,55 @@ describe("poll properties", () => {
);
});

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

await expect(
poll({
try {
await poll({
fetchState: async () => ({ value: stateValue }),
shouldStop: () => false, // Never stop
getProgressMessage: () => "Testing...",
json: true,
pollIntervalMs: 10,
timeoutMs,
timeoutMessage: customMessage,
})
).rejects.toThrow(customMessage);
});
// Should not reach here
expect.unreachable("Expected TimeoutError to be thrown");
} catch (error) {
expect(error).toBeInstanceOf(TimeoutError);
expect((error as TimeoutError).message).toBe(customMessage);
}
}),
{ numRuns: Math.min(DEFAULT_NUM_RUNS, 20) } // Fewer runs since timeout tests are slow
);
});

test("TimeoutError includes hint when provided", async () => {
const customHint = "Try running the command again.";

try {
await poll({
fetchState: async () => ({ value: 1 }),
shouldStop: () => false,
getProgressMessage: () => "Testing...",
json: true,
pollIntervalMs: 10,
timeoutMs: 50,
timeoutHint: customHint,
});
expect.unreachable("Expected TimeoutError to be thrown");
} catch (error) {
expect(error).toBeInstanceOf(TimeoutError);
const te = error as TimeoutError;
expect(te.hint).toBe(customHint);
expect(te.format()).toContain(customHint);
}
});

test("fetchState call count is bounded by timeout/interval", async () => {
await fcAssert(
asyncProperty(
Expand Down Expand Up @@ -200,7 +228,7 @@ describe("poll properties", () => {

describe("poll edge cases", () => {
test("handles immediate timeout (timeoutMs = 0)", async () => {
// With 0 timeout, should throw immediately or after first fetch
// With 0 timeout, should throw TimeoutError immediately or after first fetch
await expect(
poll({
fetchState: async () => ({ value: 1 }),
Expand All @@ -210,7 +238,7 @@ describe("poll edge cases", () => {
pollIntervalMs: 10,
timeoutMs: 0,
})
).rejects.toThrow();
).rejects.toBeInstanceOf(TimeoutError);
});

test("handles fetchState throwing errors", async () => {
Expand Down
Loading