Skip to content

Commit a55d2a8

Browse files
betegonclaude
andauthored
fix(init): show actionable error when org is over its member limit (#1091)
## Summary When you run `sentry init` against an org that's over its member limit, Sentry disables your seat and returns `401 member-disabled-over-limit` on region-scoped calls (like listing teams). Init resolved the org fine from the control-plane org list, then hit that 401 during team resolution and showed a bare: ``` ✖ Setup failed. Failed to list teams 401 ``` The generic 401 path also told the user to re-authenticate — useless here, since it's a billing/seat issue, not auth. Found while triaging a real `init` failure against an over-limit EU org. ## Changes - `enrich401Detail` recognizes the `member-disabled-over-limit` code and explains the real cause (org over member limit; ask an owner to upgrade or free a seat, or target another org) instead of the misleading re-auth advice. - The init team-resolution paths (implicit and explicit) now surface the `ApiError`'s enriched detail via `format()` rather than flattening it to `error.message`, and `resolve-team`'s `handleListTeamsError` re-throws 401s as `ApiError` so the detail survives. This mirrors the existing 401 handling for `listOrganizations` (#971). After: ``` Failed to list teams: 401 Your account is disabled in this organization because it is over its member limit. This is a billing/seat-limit issue, not an auth problem — re-authenticating won't help. Ask an org owner to upgrade the plan or free up a seat, then retry. Or target a different org, e.g.: sentry init my-other-org/ ``` ## Test Plan - New unit tests: `infrastructure.test.ts` (member-limit 401 message, and that it doesn't suggest re-auth) and `preflight.test.ts` (implicit `listTeams` 401 surfaces the detail). - `pnpm test` green, lint + `tsc --noEmit` clean. - Verified end-to-end against a real over-limit org: `sentry team list <org>/` and `sentry init <org>/` both print the new guidance and fail before writing any files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a5f26c3 commit a55d2a8

6 files changed

Lines changed: 187 additions & 7 deletions

File tree

src/lib/api/infrastructure.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,18 @@ function enrich403Detail(rawDetail: string | undefined): string {
102102
* @see https://github.com/getsentry/sentry/blob/934f1473f198a62f9268d7140b80cd9ca1e59bb9/src/sentry/api/authentication.py#L536-L539
103103
*/
104104
export function enrich401Detail(rawDetail: string | undefined): string {
105+
// Seat-limit lockout, not an auth failure. Sentry returns 401 with
106+
// `code: member-disabled-over-limit` when the org is over its member limit
107+
// and the caller's seat is disabled — re-authenticating cannot fix this.
108+
if (rawDetail?.includes("member-disabled-over-limit")) {
109+
return [
110+
"Your account is disabled in this organization because it is over its member limit.",
111+
"This is a billing/seat-limit issue, not an auth problem — re-authenticating won't help.",
112+
"Ask an org owner to upgrade the plan or free up a seat, then retry.",
113+
"Or target a different org, e.g.: sentry init my-other-org/",
114+
].join("\n ");
115+
}
116+
105117
const lines: string[] = [];
106118
if (rawDetail) {
107119
lines.push(rawDetail, "");

src/lib/init/preflight.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,23 @@ async function resolveExistingProjectChoice(opts: {
310310
};
311311
}
312312

313+
/**
314+
* Normalize a team-resolution failure into a WizardError, preserving an
315+
* ApiError's enriched detail (e.g. 401 `member-disabled-over-limit`) via
316+
* format() instead of collapsing to its bare message + status line.
317+
*/
318+
function toPreflightWizardError(error: unknown): WizardError {
319+
if (error instanceof WizardError) {
320+
return error;
321+
}
322+
if (error instanceof ApiError) {
323+
return new WizardError(error.format());
324+
}
325+
return new WizardError(
326+
error instanceof Error ? error.message : String(error)
327+
);
328+
}
329+
313330
async function resolveTeam(
314331
org: string,
315332
initial: WizardOptions,
@@ -334,9 +351,7 @@ async function resolveTeam(
334351
if (error instanceof ApiError && error.status === 403) {
335352
return;
336353
}
337-
throw error instanceof WizardError
338-
? error
339-
: new WizardError(error instanceof Error ? error.message : String(error));
354+
throw toPreflightWizardError(error);
340355
}
341356
}
342357

@@ -381,9 +396,7 @@ async function listTeamsForImplicitInit(
381396
if (error instanceof ApiError && error.status === 404) {
382397
return await buildOrgNotFoundError(org, "sentry init");
383398
}
384-
throw error instanceof WizardError
385-
? error
386-
: new WizardError(error instanceof Error ? error.message : String(error));
399+
throw toPreflightWizardError(error);
387400
}
388401
}
389402

src/lib/resolve-team.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ export type ResolvedTeam = ResolvedConcreteTeam | DeferredResolvedTeam;
119119
* - 403 → member lacks team:read; re-thrown as `ApiError` so callers that
120120
* implement a member-accessible fallback can detect it and use
121121
* POST /organizations/{org}/projects/ instead.
122+
* - 401 → re-thrown as `ApiError` so the enriched detail (expired session,
123+
* member-disabled-over-limit, etc.) survives instead of being flattened.
122124
* - other → generic ResolutionError (5xx, network, etc.)
123125
*/
124126
async function handleListTeamsError(
@@ -137,6 +139,9 @@ async function handleListTeamsError(
137139
if (error.status === 403) {
138140
throw error;
139141
}
142+
if (error.status === 401) {
143+
throw error;
144+
}
140145
throw new ResolutionError(
141146
`Organization '${orgSlug}'`,
142147
`could not be accessed (${error.status})`,

test/lib/api/infrastructure.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,39 @@ describe("throwApiError", () => {
445445
}
446446
});
447447

448+
test("treats member-disabled-over-limit as a seat-limit issue, not auth", () => {
449+
const mockResponse = new Response("", {
450+
status: 401,
451+
statusText: "Unauthorized",
452+
});
453+
454+
let captured: ApiError | undefined;
455+
try {
456+
throwApiError(
457+
{
458+
detail: {
459+
code: "member-disabled-over-limit",
460+
message: "Organization over member limit",
461+
extra: { next: "/organizations/chisme/disabled-member/" },
462+
},
463+
},
464+
mockResponse,
465+
"Failed to list teams"
466+
);
467+
} catch (error) {
468+
captured = error as ApiError;
469+
}
470+
471+
expect(captured).toBeDefined();
472+
expect(captured?.status).toBe(401);
473+
expect(captured?.detail).toContain("over its member limit");
474+
expect(captured?.detail).toContain("billing/seat-limit");
475+
// The fix must NOT give the misleading re-auth advice for this case.
476+
expect(captured?.detail).not.toContain("sentry auth login");
477+
expect(captured?.detail).not.toContain("session has expired");
478+
expect(captured?.detail).not.toContain("SENTRY_AUTH_TOKEN");
479+
});
480+
448481
describe("with OAuth token (no env var)", () => {
449482
let savedAuthToken: string | undefined;
450483
let savedToken: string | undefined;

test/lib/init/preflight.test.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ vi.mock("../../../src/lib/dsn/index.js", async (importOriginal) => {
4646

4747
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
4848
import * as dsnIndex from "../../../src/lib/dsn/index.js";
49-
import { ApiError } from "../../../src/lib/errors.js";
49+
import { ApiError, WizardError } from "../../../src/lib/errors.js";
5050

5151
vi.mock("../../../src/lib/init/org-prefetch.js", async (importOriginal) => {
5252
const actual =
@@ -583,6 +583,80 @@ describe("resolveInitContext", () => {
583583
expect(errorCall?.message).toContain("beta");
584584
});
585585

586+
test("surfaces the enriched detail when implicit listTeams returns 401", async () => {
587+
// member-disabled-over-limit: a 401 from listTeams must reach the user with
588+
// its actionable detail, not a bare "Failed to list teams" + status line.
589+
listTeamsSpy.mockRejectedValueOnce(
590+
new ApiError(
591+
"Failed to list teams",
592+
401,
593+
"Your account is disabled in this organization because it is over its member limit."
594+
)
595+
);
596+
597+
const { ui, calls } = createMockUI();
598+
await expect(resolveInitContext(makeOptions(), ui)).rejects.toThrow();
599+
600+
const errorCall = calls.find(
601+
(c): c is Extract<MockCall, { kind: "log.error" }> =>
602+
c.kind === "log.error"
603+
);
604+
expect(errorCall?.message).toContain("over its member limit");
605+
});
606+
607+
test("surfaces the enriched detail when explicit --team listTeams returns 401", async () => {
608+
resolveOrCreateTeamSpy.mockRejectedValueOnce(
609+
new ApiError(
610+
"Failed to list teams",
611+
401,
612+
"Your account is disabled in this organization because it is over its member limit."
613+
)
614+
);
615+
616+
const { ui, calls } = createMockUI();
617+
await expect(
618+
resolveInitContext(makeOptions({ team: "backend" }), ui)
619+
).rejects.toThrow();
620+
621+
const errorCall = calls.find(
622+
(c): c is Extract<MockCall, { kind: "log.error" }> =>
623+
c.kind === "log.error"
624+
);
625+
expect(errorCall?.message).toContain("over its member limit");
626+
});
627+
628+
test("passes a pre-rendered WizardError through team resolution unchanged", async () => {
629+
listTeamsSpy.mockRejectedValueOnce(
630+
new WizardError("custom preflight failure")
631+
);
632+
633+
const { ui, calls } = createMockUI();
634+
await expect(resolveInitContext(makeOptions(), ui)).rejects.toThrow(
635+
"custom preflight failure"
636+
);
637+
638+
const errorCall = calls.find(
639+
(c): c is Extract<MockCall, { kind: "log.error" }> =>
640+
c.kind === "log.error"
641+
);
642+
expect(errorCall?.message).toBe("custom preflight failure");
643+
});
644+
645+
test("surfaces a non-API error message from implicit team resolution", async () => {
646+
listTeamsSpy.mockRejectedValueOnce(new Error("network down"));
647+
648+
const { ui, calls } = createMockUI();
649+
await expect(resolveInitContext(makeOptions(), ui)).rejects.toThrow(
650+
"network down"
651+
);
652+
653+
const errorCall = calls.find(
654+
(c): c is Extract<MockCall, { kind: "log.error" }> =>
655+
c.kind === "log.error"
656+
);
657+
expect(errorCall?.message).toContain("network down");
658+
});
659+
586660
test("fails early when listTeams is forbidden and member project creation is disabled", async () => {
587661
listTeamsSpy.mockRejectedValueOnce(
588662
new ApiError("Forbidden", 403, "No team:read access")

test/lib/resolve-team.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Tests for resolveOrCreateTeam error handling. Mocks the teams API so
3+
* listTeams failures can be exercised without real HTTP calls.
4+
*/
5+
6+
import { afterEach, describe, expect, test, vi } from "vitest";
7+
8+
vi.mock("../../src/lib/api/teams.js");
9+
vi.mock("../../src/lib/api/organizations.js");
10+
11+
// biome-ignore lint/performance/noNamespaceImport: needed for vi.spyOn mocking
12+
import * as teamsApi from "../../src/lib/api/teams.js";
13+
import { ApiError, ResolutionError } from "../../src/lib/errors.js";
14+
import { resolveOrCreateTeam } from "../../src/lib/resolve-team.js";
15+
16+
describe("resolveOrCreateTeam", () => {
17+
const listTeamsSpy = vi.mocked(teamsApi.listTeams);
18+
19+
afterEach(() => {
20+
listTeamsSpy.mockReset();
21+
});
22+
23+
test("re-throws the original ApiError when listTeams returns 401", async () => {
24+
// member-disabled-over-limit and other 401s must keep their enriched detail
25+
// instead of being flattened into a generic ResolutionError.
26+
const apiError = new ApiError(
27+
"Failed to list teams",
28+
401,
29+
"Your account is disabled in this organization because it is over its member limit."
30+
);
31+
listTeamsSpy.mockRejectedValueOnce(apiError);
32+
33+
const error = await resolveOrCreateTeam("chisme", {
34+
usageHint: "sentry init",
35+
}).catch((e) => e);
36+
37+
expect(error).toBe(apiError);
38+
expect(error).toBeInstanceOf(ApiError);
39+
expect(error).not.toBeInstanceOf(ResolutionError);
40+
expect(error.status).toBe(401);
41+
expect(error.detail).toContain("over its member limit");
42+
});
43+
});

0 commit comments

Comments
 (0)