Skip to content

Commit 1f2aac5

Browse files
betegonclaude
andcommitted
fix(init): show actionable error when org is over its member limit
When the target org is over its member limit, Sentry disables the caller's seat and returns 401 `member-disabled-over-limit` for region-scoped calls like listing teams. `sentry init` resolved the org fine from the control-plane org list, then hit this 401 during team resolution and surfaced a bare "Failed to list teams / 401" — and the generic 401 path told the user to re-authenticate, which can't fix a seat-limit lockout. Recognize the code in enrich401Detail and explain the real cause (org over member limit, ask an owner to upgrade/free a seat, or use another org). Preserve the enriched detail through the init team-resolution paths (implicit and explicit) and resolve-team's 401 handling so the guidance reaches the user instead of being flattened. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a5f26c3 commit 1f2aac5

5 files changed

Lines changed: 90 additions & 6 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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,27 @@ 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+
586607
test("fails early when listTeams is forbidden and member project creation is disabled", async () => {
587608
listTeamsSpy.mockRejectedValueOnce(
588609
new ApiError("Forbidden", 403, "No team:read access")

0 commit comments

Comments
 (0)