Skip to content

Commit c290a10

Browse files
authored
feat(cli): Tell users when Seer is out of credits (#2150)
`sentry issue explain` and `sentry issue plan` treat every 402 from the autofix endpoint as "Seer requires a paid plan" and offer a free trial. An org on token-based billing that runs out of Seer credits gets that message too. A run that starts and then stops for credits reads as "Analysis completed but no root causes found" or "Plan creation failed". - A 402 with `code: "seer_out_of_credits"` (added in getsentry/sentry#130284) now raises a new `out_of_credits` Seer error. Any other 402, including `seer_out_of_quota` and the 402 with no code that older Sentry versions send, keeps the current no-budget error. - A run that ends with `failure_reason: "out_of_credits"` raises the same error from both commands. - The error has its own exit code, 43, and is left out of the free trial prompt. What the user sees: ``` Your organization is out of Seer credits. Someone with billing access can adjust Seer spend and auto-reload in Sentry: https://my-org.sentry.io/settings/billing/overview/?product=seer ``` A failed run is still retried on the next invocation, so someone who topped up recovers by running the command again. The 402 comes from getsentry/sentry#130283 and getsentry/sentry#130284, which have both merged. Sentry's credit checks are behind the `organizations:seer-credit-checks` flag, so for an org without it the CLI behaves as it does today. The MCP equivalent is #2031. Fixes CW-3075
1 parent a1d68b1 commit c290a10

15 files changed

Lines changed: 227 additions & 16 deletions

File tree

‎apps/cli-docs/src/content/docs/exit-codes.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ react to failure categories without parsing stderr.
3939
| 40 | Seer Not Enabled | Seer is not enabled for the organization |
4040
| 41 | Seer No Budget | Seer requires a paid plan |
4141
| 42 | AI Disabled | AI features disabled by organization admin |
42+
| 43 | Seer Out Of Credits | Organization is out of Seer credits |
4243
| 50 | Upgrade Error | CLI upgrade operation failed |
4344
| 51 | Device Flow Error | OAuth device authorization flow failed |
4445
| 60 | Output Error | Command produced output but the operation failed |

‎packages/cli/AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -907,7 +907,7 @@ CliError (base, exitCode=1)
907907
├── ResolutionError (exitCode=23 — value provided but not found)
908908
├── ValidationError (exitCode=21 — input validation)
909909
├── DeviceFlowError (exitCode=51 — OAuth flow)
910-
├── SeerError (exitCode=40–42 by reason — 'not_enabled' | 'no_budget' | 'ai_disabled')
910+
├── SeerError (exitCode=40–43 by reason — 'not_enabled' | 'no_budget' | 'ai_disabled' | 'out_of_credits')
911911
├── TimeoutError (exitCode=31 — operation timed out)
912912
└── UpgradeError (exitCode=50 — upgrade failures)
913913
```

‎packages/cli/src/commands/issue/explain.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,12 @@ async function analyzeIssue(
7474
return { issue: issueArg.text, org, issueId, rootCauses };
7575
} catch (error) {
7676
if (error instanceof ApiError) {
77-
throw handleSeerApiError(error.status, error.detail, resolvedOrg);
77+
throw handleSeerApiError(
78+
error.status,
79+
error.detail,
80+
resolvedOrg,
81+
error.code,
82+
);
7883
}
7984
throw error;
8085
}

‎packages/cli/src/commands/issue/plan.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { triggerSolutionPlanning } from "../../lib/api-client.js";
1111
import { sentry } from "../../lib/app-identity.js";
1212
import { issueSelectorHelp } from "../../lib/arg-parsing.js";
1313
import { buildCommand } from "../../lib/command.js";
14-
import { ApiError, CliError } from "../../lib/errors.js";
14+
import { ApiError, CliError, SeerError } from "../../lib/errors.js";
1515
import { CommandOutput } from "../../lib/formatters/output.js";
1616
import {
1717
formatSolution,
@@ -323,6 +323,9 @@ export const planCommand = buildCommand({
323323
});
324324

325325
if (finalState.status === "ERROR") {
326+
if (finalState.failure_reason === "out_of_credits") {
327+
throw new SeerError("out_of_credits", org);
328+
}
326329
throw new Error(
327330
"Plan creation failed. Check the Sentry web UI for details.",
328331
);
@@ -342,7 +345,12 @@ export const planCommand = buildCommand({
342345
return yield new CommandOutput(buildPlanData(finalState));
343346
} catch (error) {
344347
if (error instanceof ApiError) {
345-
throw handleSeerApiError(error.status, error.detail, resolvedOrg);
348+
throw handleSeerApiError(
349+
error.status,
350+
error.detail,
351+
resolvedOrg,
352+
error.code,
353+
);
346354
}
347355
throw error;
348356
}

‎packages/cli/src/commands/issue/utils.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
type AuthGuardFailure,
3737
ContextError,
3838
ResolutionError,
39+
SeerError,
3940
withAuthGuard,
4041
} from "../../lib/errors.js";
4142
import { getProgressMessage } from "../../lib/formatters/seer.js";
@@ -1086,6 +1087,7 @@ type EnsureRootCauseOptions = {
10861087
*
10871088
* @param options - Configuration options
10881089
* @returns The completed autofix state with root causes
1090+
* @throws {SeerError} When the run stopped because the org ran out of Seer credits
10891091
*/
10901092
export async function ensureRootCauseAnalysis(
10911093
options: EnsureRootCauseOptions,
@@ -1126,6 +1128,10 @@ export async function ensureRootCauseAnalysis(
11261128
});
11271129
}
11281130

1131+
if (state.status === "ERROR" && state.failure_reason === "out_of_credits") {
1132+
throw new SeerError("out_of_credits", org);
1133+
}
1134+
11291135
return state;
11301136
}
11311137

‎packages/cli/src/lib/errors.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ export const EXIT = {
8383
SEER_NO_BUDGET: 41,
8484
/** AI features disabled by org admin */
8585
SEER_AI_DISABLED: 42,
86+
/** Organization is out of Seer credits */
87+
SEER_OUT_OF_CREDITS: 43,
8688

8789
// 50–59: Operations
8890
/** CLI upgrade failed */
@@ -757,7 +759,11 @@ export class UpgradeTransportError extends UpgradeError {
757759

758760
// Seer Errors
759761

760-
export type SeerErrorReason = "not_enabled" | "no_budget" | "ai_disabled";
762+
export type SeerErrorReason =
763+
| "not_enabled"
764+
| "no_budget"
765+
| "ai_disabled"
766+
| "out_of_credits";
761767

762768
/**
763769
* Seer-specific errors with actionable suggestions.
@@ -774,11 +780,13 @@ export class SeerError extends CliError {
774780
not_enabled: "Seer is not enabled for this organization.",
775781
no_budget: "Seer requires a paid plan.",
776782
ai_disabled: "AI features are disabled for this organization.",
783+
out_of_credits: "Your organization is out of Seer credits.",
777784
};
778785
const exitCodes: Record<SeerErrorReason, number> = {
779786
not_enabled: EXIT.SEER_NOT_ENABLED,
780787
no_budget: EXIT.SEER_NO_BUDGET,
781788
ai_disabled: EXIT.SEER_AI_DISABLED,
789+
out_of_credits: EXIT.SEER_OUT_OF_CREDITS,
782790
};
783791
super(messages[reason], exitCodes[reason]);
784792
this.name = "SeerError";
@@ -803,6 +811,10 @@ export class SeerError extends CliError {
803811
no_budget: `To use Seer features, upgrade your plan:\n ${buildBillingUrl(this.orgSlug, "seer")}${trialHint}`,
804812
// ai_disabled is an admin decision — don't suggest trial
805813
ai_disabled: `To enable AI features:\n ${buildOrgSettingsUrl(this.orgSlug, "hideAiFeatures")}`,
814+
// out_of_credits orgs already have Seer, so a trial is not the fix.
815+
// Sentry doesn't say whether auto-reload is off or has run through its
816+
// top-ups, so the advice covers both.
817+
out_of_credits: `Someone with billing access can adjust Seer spend and auto-reload in Sentry:\n ${buildBillingUrl(this.orgSlug, "seer")}`,
806818
};
807819
return `${this.message}\n\n${suggestions[this.reason]}`;
808820
}
@@ -814,6 +826,9 @@ export class SeerError extends CliError {
814826
// ai_disabled is an admin decision — don't suggest trial
815827
ai_disabled:
816828
"To enable AI features, check the 'Hide AI Features' setting in your organization settings.",
829+
// out_of_credits orgs already have Seer, so a trial is not the fix
830+
out_of_credits:
831+
"Someone with billing access can adjust Seer spend and auto-reload in your organization's billing settings.",
817832
};
818833
return `${this.message}\n\n${fallbackSuggestions[this.reason]}`;
819834
}

‎packages/cli/src/lib/formatters/seer.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,21 +260,33 @@ export function jsonTransformIssueExplain(
260260

261261
// Error Messages
262262

263+
/**
264+
* `code` Sentry sends with a 402 when the organization is out of Seer credits.
265+
* Any other 402 means no budget, including one with no code from a Sentry
266+
* version that predates the codes.
267+
*/
268+
const OUT_OF_CREDITS_CODE = "seer_out_of_credits";
269+
263270
/**
264271
* Create a SeerError from an API error status code and detail.
265272
*
266273
* @param status - HTTP status code
267274
* @param detail - Error detail from API
268275
* @param orgSlug - Organization slug for constructing settings URLs
276+
* @param code - Machine-readable `code` from the error body
269277
* @returns SeerError if the status code indicates a Seer-specific error, null otherwise
270278
*/
271279
export function createSeerError(
272280
status: number,
273281
detail?: string,
274282
orgSlug?: string,
283+
code?: string,
275284
): SeerError | null {
276285
if (status === 402) {
277-
return new SeerError("no_budget", orgSlug);
286+
return new SeerError(
287+
code === OUT_OF_CREDITS_CODE ? "out_of_credits" : "no_budget",
288+
orgSlug,
289+
);
278290
}
279291
if (status === 403) {
280292
if (detail?.includes("not enabled")) {
@@ -301,14 +313,16 @@ export function createSeerError(
301313
* @param status - HTTP status code
302314
* @param detail - Error detail from API
303315
* @param orgSlug - Organization slug for constructing settings URLs
316+
* @param code - Machine-readable `code` from the error body
304317
* @returns SeerError for Seer-specific errors, or ApiError for other API errors
305318
*/
306319
export function handleSeerApiError(
307320
status: number,
308321
detail?: string,
309322
orgSlug?: string,
323+
code?: string,
310324
): Error {
311-
const seerError = createSeerError(status, detail, orgSlug);
325+
const seerError = createSeerError(status, detail, orgSlug, code);
312326
if (seerError) {
313327
return seerError;
314328
}

‎packages/cli/src/types/seer.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,11 @@ export const AutofixStateSchema = looseObject({
189189
/** Null for legacy runs predating this field. */
190190
sentry_run_id: optional(nullable(string())),
191191
status: string(),
192+
/**
193+
* Why a failed run stopped, e.g. `out_of_credits`. Null when the run didn't
194+
* fail, and also when it failed for a reason Seer doesn't classify.
195+
*/
196+
failure_reason: optional(nullable(string())),
192197
updated_at: optional(string()),
193198
request: optional(
194199
object({

‎packages/cli/test/commands/issue/explain.func.test.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ vi.mock("../../../src/commands/issue/utils.js", async (importOriginal) => {
2020
import { explainCommand } from "../../../src/commands/issue/explain.js";
2121
// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking
2222
import * as issueUtils from "../../../src/commands/issue/utils.js";
23-
import { ContextError } from "../../../src/lib/errors.js";
23+
import { ApiError, ContextError } from "../../../src/lib/errors.js";
2424
import type { IssueArgument } from "../../../src/lib/issue-arguments.js";
2525
import type { AutofixState, RootCause } from "../../../src/types/seer.js";
2626

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

175+
test("throws out_of_credits when Sentry refuses the analysis with a 402", async () => {
176+
resolveSpy.mockResolvedValue({ org: "test-org", issueId: "1" });
177+
analyzeSpy.mockRejectedValue(
178+
new ApiError(
179+
"Failed to start root cause analysis: 402 Payment Required",
180+
402,
181+
"Your organization does not have enough credits to start an Autofix run.",
182+
undefined,
183+
{ code: "seer_out_of_credits" },
184+
),
185+
);
186+
187+
const func = await explainCommand.loader();
188+
await expect(
189+
func.call(createMockContext().context, EXPLAIN_FLAGS, "IOS-1"),
190+
).rejects.toMatchObject({ reason: "out_of_credits", orgSlug: "test-org" });
191+
});
192+
175193
test("fails to parse when no issue ID is provided", async () => {
176194
const func = await explainCommand.loader();
177195
await expect(

‎packages/cli/test/commands/issue/plan.func.test.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { planCommand } from "../../../src/commands/issue/plan.js";
2323
// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking
2424
import * as issueUtils from "../../../src/commands/issue/utils.js";
2525
import { triggerSolutionPlanning } from "../../../src/lib/api-client.js";
26-
import { CliError } from "../../../src/lib/errors.js";
26+
import { ApiError, CliError } from "../../../src/lib/errors.js";
2727
import { logger } from "../../../src/lib/logger.js";
2828
import { prompt } from "../../../src/lib/prompt.js";
2929
import type { AutofixState, RootCause } from "../../../src/types/seer.js";
@@ -191,6 +191,38 @@ describe("issue plan after solution planning", () => {
191191
);
192192
});
193193

194+
test("throws out_of_credits when planning stopped for credits", async () => {
195+
pollSpy.mockResolvedValue({
196+
status: "ERROR",
197+
failure_reason: "out_of_credits",
198+
run_id: 42,
199+
steps: [],
200+
});
201+
202+
await expect(runPlan(createMockContext())).rejects.toMatchObject({
203+
reason: "out_of_credits",
204+
orgSlug: "test-org",
205+
});
206+
});
207+
208+
test("throws out_of_credits when Sentry refuses planning with a 402", async () => {
209+
vi.mocked(triggerSolutionPlanning).mockRejectedValueOnce(
210+
new ApiError(
211+
"Failed to start solution planning: 402 Payment Required",
212+
402,
213+
"Your organization does not have enough credits to continue your Autofix run.",
214+
undefined,
215+
{ code: "seer_out_of_credits" },
216+
),
217+
);
218+
219+
await expect(runPlan(createMockContext())).rejects.toMatchObject({
220+
reason: "out_of_credits",
221+
orgSlug: "test-org",
222+
});
223+
expect(pollSpy).not.toHaveBeenCalled();
224+
});
225+
194226
test("throws when planning ends in CANCELLED", async () => {
195227
pollSpy.mockResolvedValue({ status: "CANCELLED", run_id: 42, steps: [] });
196228

0 commit comments

Comments
 (0)