Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/cli-docs/src/content/docs/exit-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ react to failure categories without parsing stderr.
| 40 | Seer Not Enabled | Seer is not enabled for the organization |
| 41 | Seer No Budget | Seer requires a paid plan |
| 42 | AI Disabled | AI features disabled by organization admin |
| 43 | Seer Out Of Credits | Organization is out of Seer credits |
| 50 | Upgrade Error | CLI upgrade operation failed |
| 51 | Device Flow Error | OAuth device authorization flow failed |
| 60 | Output Error | Command produced output but the operation failed |
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -907,7 +907,7 @@ CliError (base, exitCode=1)
├── ResolutionError (exitCode=23 — value provided but not found)
├── ValidationError (exitCode=21 — input validation)
├── DeviceFlowError (exitCode=51 — OAuth flow)
├── SeerError (exitCode=40–42 by reason — 'not_enabled' | 'no_budget' | 'ai_disabled')
├── SeerError (exitCode=40–43 by reason — 'not_enabled' | 'no_budget' | 'ai_disabled' | 'out_of_credits')
├── TimeoutError (exitCode=31 — operation timed out)
└── UpgradeError (exitCode=50 — upgrade failures)
```
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/commands/issue/explain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,12 @@ async function analyzeIssue(
return { issue: issueArg.text, org, issueId, rootCauses };
} catch (error) {
if (error instanceof ApiError) {
throw handleSeerApiError(error.status, error.detail, resolvedOrg);
throw handleSeerApiError(
error.status,
error.detail,
resolvedOrg,
error.code,
);
}
throw error;
}
Expand Down
12 changes: 10 additions & 2 deletions packages/cli/src/commands/issue/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { triggerSolutionPlanning } from "../../lib/api-client.js";
import { sentry } from "../../lib/app-identity.js";
import { issueSelectorHelp } from "../../lib/arg-parsing.js";
import { buildCommand } from "../../lib/command.js";
import { ApiError, CliError } from "../../lib/errors.js";
import { ApiError, CliError, SeerError } from "../../lib/errors.js";
import { CommandOutput } from "../../lib/formatters/output.js";
import {
formatSolution,
Expand Down Expand Up @@ -323,6 +323,9 @@ export const planCommand = buildCommand({
});

if (finalState.status === "ERROR") {
if (finalState.failure_reason === "out_of_credits") {
throw new SeerError("out_of_credits", org);
}
throw new Error(
"Plan creation failed. Check the Sentry web UI for details.",
);
Expand All @@ -342,7 +345,12 @@ export const planCommand = buildCommand({
return yield new CommandOutput(buildPlanData(finalState));
} catch (error) {
if (error instanceof ApiError) {
throw handleSeerApiError(error.status, error.detail, resolvedOrg);
throw handleSeerApiError(
error.status,
error.detail,
resolvedOrg,
error.code,
);
}
throw error;
}
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/commands/issue/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
type AuthGuardFailure,
ContextError,
ResolutionError,
SeerError,
withAuthGuard,
} from "../../lib/errors.js";
import { getProgressMessage } from "../../lib/formatters/seer.js";
Expand Down Expand Up @@ -1086,6 +1087,7 @@ type EnsureRootCauseOptions = {
*
* @param options - Configuration options
* @returns The completed autofix state with root causes
* @throws {SeerError} When the run stopped because the org ran out of Seer credits
*/
export async function ensureRootCauseAnalysis(
options: EnsureRootCauseOptions,
Expand Down Expand Up @@ -1126,6 +1128,10 @@ export async function ensureRootCauseAnalysis(
});
}

if (state.status === "ERROR" && state.failure_reason === "out_of_credits") {
throw new SeerError("out_of_credits", org);
}

return state;
}

Expand Down
17 changes: 16 additions & 1 deletion packages/cli/src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export const EXIT = {
SEER_NO_BUDGET: 41,
/** AI features disabled by org admin */
SEER_AI_DISABLED: 42,
/** Organization is out of Seer credits */
SEER_OUT_OF_CREDITS: 43,

// 50–59: Operations
/** CLI upgrade failed */
Expand Down Expand Up @@ -700,7 +702,11 @@ export class UpgradeTransportError extends UpgradeError {

// Seer Errors

export type SeerErrorReason = "not_enabled" | "no_budget" | "ai_disabled";
export type SeerErrorReason =
| "not_enabled"
| "no_budget"
| "ai_disabled"
| "out_of_credits";

/**
* Seer-specific errors with actionable suggestions.
Expand All @@ -717,11 +723,13 @@ export class SeerError extends CliError {
not_enabled: "Seer is not enabled for this organization.",
no_budget: "Seer requires a paid plan.",
ai_disabled: "AI features are disabled for this organization.",
out_of_credits: "Your organization is out of Seer credits.",
};
const exitCodes: Record<SeerErrorReason, number> = {
not_enabled: EXIT.SEER_NOT_ENABLED,
no_budget: EXIT.SEER_NO_BUDGET,
ai_disabled: EXIT.SEER_AI_DISABLED,
out_of_credits: EXIT.SEER_OUT_OF_CREDITS,
};
super(messages[reason], exitCodes[reason]);
this.name = "SeerError";
Expand All @@ -746,6 +754,10 @@ export class SeerError extends CliError {
no_budget: `To use Seer features, upgrade your plan:\n ${buildBillingUrl(this.orgSlug, "seer")}${trialHint}`,
// ai_disabled is an admin decision — don't suggest trial
ai_disabled: `To enable AI features:\n ${buildOrgSettingsUrl(this.orgSlug, "hideAiFeatures")}`,
// out_of_credits orgs already have Seer, so a trial is not the fix.
// Sentry doesn't say whether auto-reload is off or has run through its
// top-ups, so the advice covers both.
out_of_credits: `Someone with billing access can adjust Seer spend and auto-reload in Sentry:\n ${buildBillingUrl(this.orgSlug, "seer")}`,
};
return `${this.message}\n\n${suggestions[this.reason]}`;
}
Expand All @@ -757,6 +769,9 @@ export class SeerError extends CliError {
// ai_disabled is an admin decision — don't suggest trial
ai_disabled:
"To enable AI features, check the 'Hide AI Features' setting in your organization settings.",
// out_of_credits orgs already have Seer, so a trial is not the fix
out_of_credits:
"Someone with billing access can adjust Seer spend and auto-reload in your organization's billing settings.",
};
return `${this.message}\n\n${fallbackSuggestions[this.reason]}`;
}
Expand Down
18 changes: 16 additions & 2 deletions packages/cli/src/lib/formatters/seer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,21 +260,33 @@ export function jsonTransformIssueExplain(

// Error Messages

/**
* `code` Sentry sends with a 402 when the organization is out of Seer credits.
* Any other 402 means no budget, including one with no code from a Sentry
* version that predates the codes.
*/
const OUT_OF_CREDITS_CODE = "seer_out_of_credits";

/**
* Create a SeerError from an API error status code and detail.
*
* @param status - HTTP status code
* @param detail - Error detail from API
* @param orgSlug - Organization slug for constructing settings URLs
* @param code - Machine-readable `code` from the error body
* @returns SeerError if the status code indicates a Seer-specific error, null otherwise
*/
export function createSeerError(
status: number,
detail?: string,
orgSlug?: string,
code?: string,
): SeerError | null {
if (status === 402) {
return new SeerError("no_budget", orgSlug);
return new SeerError(
code === OUT_OF_CREDITS_CODE ? "out_of_credits" : "no_budget",
orgSlug,
);
}
if (status === 403) {
if (detail?.includes("not enabled")) {
Expand All @@ -301,14 +313,16 @@ export function createSeerError(
* @param status - HTTP status code
* @param detail - Error detail from API
* @param orgSlug - Organization slug for constructing settings URLs
* @param code - Machine-readable `code` from the error body
* @returns SeerError for Seer-specific errors, or ApiError for other API errors
*/
export function handleSeerApiError(
status: number,
detail?: string,
orgSlug?: string,
code?: string,
): Error {
const seerError = createSeerError(status, detail, orgSlug);
const seerError = createSeerError(status, detail, orgSlug, code);
if (seerError) {
return seerError;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/types/seer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@ export const AutofixStateSchema = looseObject({
/** Null for legacy runs predating this field. */
sentry_run_id: optional(nullable(string())),
status: string(),
/**
* Why a failed run stopped, e.g. `out_of_credits`. Null when the run didn't
* fail, and also when it failed for a reason Seer doesn't classify.
*/
failure_reason: optional(nullable(string())),
updated_at: optional(string()),
request: optional(
object({
Expand Down
20 changes: 19 additions & 1 deletion packages/cli/test/commands/issue/explain.func.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ vi.mock("../../../src/commands/issue/utils.js", async (importOriginal) => {
import { explainCommand } from "../../../src/commands/issue/explain.js";
// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking
import * as issueUtils from "../../../src/commands/issue/utils.js";
import { ContextError } from "../../../src/lib/errors.js";
import { ApiError, ContextError } from "../../../src/lib/errors.js";
import type { IssueArgument } from "../../../src/lib/issue-arguments.js";
import type { AutofixState, RootCause } from "../../../src/types/seer.js";

Expand Down Expand Up @@ -172,6 +172,24 @@ describe("issue explain multiple IDs", () => {
).rejects.toBe(error);
});

test("throws out_of_credits when Sentry refuses the analysis with a 402", async () => {
resolveSpy.mockResolvedValue({ org: "test-org", issueId: "1" });
analyzeSpy.mockRejectedValue(
new ApiError(
"Failed to start root cause analysis: 402 Payment Required",
402,
"Your organization does not have enough credits to start an Autofix run.",
undefined,
{ code: "seer_out_of_credits" },
),
);

const func = await explainCommand.loader();
await expect(
func.call(createMockContext().context, EXPLAIN_FLAGS, "IOS-1"),
).rejects.toMatchObject({ reason: "out_of_credits", orgSlug: "test-org" });
});

test("fails to parse when no issue ID is provided", async () => {
const func = await explainCommand.loader();
await expect(
Expand Down
34 changes: 33 additions & 1 deletion packages/cli/test/commands/issue/plan.func.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { planCommand } from "../../../src/commands/issue/plan.js";
// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking
import * as issueUtils from "../../../src/commands/issue/utils.js";
import { triggerSolutionPlanning } from "../../../src/lib/api-client.js";
import { CliError } from "../../../src/lib/errors.js";
import { ApiError, CliError } from "../../../src/lib/errors.js";
import { logger } from "../../../src/lib/logger.js";
import { prompt } from "../../../src/lib/prompt.js";
import type { AutofixState, RootCause } from "../../../src/types/seer.js";
Expand Down Expand Up @@ -191,6 +191,38 @@ describe("issue plan after solution planning", () => {
);
});

test("throws out_of_credits when planning stopped for credits", async () => {
pollSpy.mockResolvedValue({
status: "ERROR",
failure_reason: "out_of_credits",
run_id: 42,
steps: [],
});

await expect(runPlan(createMockContext())).rejects.toMatchObject({
reason: "out_of_credits",
orgSlug: "test-org",
});
});

test("throws out_of_credits when Sentry refuses planning with a 402", async () => {
vi.mocked(triggerSolutionPlanning).mockRejectedValueOnce(
new ApiError(
"Failed to start solution planning: 402 Payment Required",
402,
"Your organization does not have enough credits to continue your Autofix run.",
undefined,
{ code: "seer_out_of_credits" },
),
);

await expect(runPlan(createMockContext())).rejects.toMatchObject({
reason: "out_of_credits",
orgSlug: "test-org",
});
expect(pollSpy).not.toHaveBeenCalled();
});

test("throws when planning ends in CANCELLED", async () => {
pollSpy.mockResolvedValue({ status: "CANCELLED", run_id: 42, steps: [] });

Expand Down
30 changes: 30 additions & 0 deletions packages/cli/test/commands/issue/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1584,6 +1584,36 @@ describe("ensureRootCauseAnalysis", () => {
expect(triggerCalled).toBe(true); // Should have retried
});

test("throws out_of_credits when the run stopped for credits", async () => {
// @ts-expect-error - partial mock
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const req = new Request(input, init);
const body =
req.method === "POST"
? { run_id: 12_346 }
: {
autofix: {
run_id: 12_346,
status: "ERROR",
failure_reason: "out_of_credits",
steps: [],
},
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};

await expect(
ensureRootCauseAnalysis({
org: "test-org",
issueId: "123456789",
json: true,
}),
).rejects.toMatchObject({ reason: "out_of_credits", orgSlug: "test-org" });
});

test("polls until complete when state is PROCESSING", async () => {
let fetchCount = 0;

Expand Down
19 changes: 19 additions & 0 deletions packages/cli/test/lib/api-client.seer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,25 @@ describe("triggerRootCauseAnalysis", () => {
).rejects.toThrow();
});

test("keeps the code from a 402 body on the ApiError", async () => {
globalThis.fetch = async () =>
new Response(
JSON.stringify({
detail:
"Your organization does not have enough credits to start an Autofix run.",
code: "seer_out_of_credits",
}),
{ status: 402, headers: { "Content-Type": "application/json" } },
);

const error = await triggerRootCauseAnalysis("test-org", "123456789").catch(
(caught: unknown) => caught,
);

expect(error).toBeInstanceOf(ApiError);
expect(error).toMatchObject({ status: 402, code: "seer_out_of_credits" });
});

test("throws ApiError on 403 response", async () => {
globalThis.fetch = async () =>
new Response(JSON.stringify({ detail: "AI Autofix is not enabled" }), {
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/test/lib/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,14 @@ describe("SeerError", () => {
expect(formatted).toContain("my-org");
});

test("format() includes billing URL for out_of_credits with orgSlug", () => {
const formatted = new SeerError("out_of_credits", "my-org").format();
expect(formatted).toContain("Your organization is out of Seer credits.");
expect(formatted).toContain(
"https://my-org.sentry.io/settings/billing/overview/?product=seer",
);
});

test("format() shows fallback message without orgSlug", () => {
const err = new SeerError("not_enabled");
const formatted = err.format();
Expand Down Expand Up @@ -778,6 +786,9 @@ describe("exit codes", () => {
expect(new SeerError("not_enabled").exitCode).toBe(EXIT.SEER_NOT_ENABLED);
expect(new SeerError("no_budget").exitCode).toBe(EXIT.SEER_NO_BUDGET);
expect(new SeerError("ai_disabled").exitCode).toBe(EXIT.SEER_AI_DISABLED);
expect(new SeerError("out_of_credits").exitCode).toBe(
EXIT.SEER_OUT_OF_CREDITS,
);
});

test("TimeoutError has exit code TIMEOUT", () => {
Expand Down
Loading
Loading