authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-10-14 10:35:52+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 10:35:52+05:30
logd765d03cb7f980794c409c38e6ea5e25d89fde33
tree3b9ff4f2b091b97c9a907c5620796678f91bda64
parentcb65643b076c79cfe39a5197553b143889da64ab
signature Signed by PGP key B5690EEEBB952194

fix(server): a Claude 403 no longer tells you to log in again (#15872)

Co-authored-by: PR Batch Tester <agent@local.test> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

2 files changed, 53 insertions(+), 5 deletions(-)

apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+43
......@@ -3197,6 +3197,49 @@ describe("ClaudeAdapterV2 background wake turns", () => {
31973197 ),
31983198 );
31993199
3200 it.effect("shows a 403 permission error instead of the login hint", () =>
3201 Effect.gen(function* () {
3202 const harness = yield* makeWakeHarness;
3203 const now = yield* DateTime.now;
3204 yield* harness.runtime.startTurn(
3205 makeClaudeTestTurnInput({
3206 threadId: harness.threadId,
3207 providerThread: harness.providerThread,
3208 now,
3209 attemptId: RunAttemptId.make("attempt-claude-permission-denied"),
3210 text: "Continue.",
3211 attachments: [],
3212 }),
3213 );
3214 // Claude CLI 2.1.288 labels a 403 authentication_failed too.
3215 const cliText =
3216 "Failed to authenticate. API Error: 403 Your organization does not have access to this model.";
3217 yield* Queue.offerAll(harness.sdkMessages, [
3218 makeAssistantErrorFrame({
3219 uuid: "00000000-0000-4000-8000-000000000608",
3220 error: "authentication_failed",
3221 }),
3222 makeResultFrame({
3223 uuid: "00000000-0000-4000-8000-000000000609",
3224 result: cliText,
3225 isError: true,
3226 apiErrorStatus: 403,
3227 terminalReason: "api_error",
3228 }),
3229 ]);
3230
3231 const terminal = yield* Queue.take(harness.terminalReceipts);
3232 assert.equal(terminal.status, "failed");
3233 if (terminal.status !== "failed") return;
3234 assert.equal(terminal.failure.message, cliText);
3235 assert.equal(terminal.failure.code, "api_error_403");
3236 }).pipe(
3237 Effect.provide(
3238 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
3239 ),
3240 ),
3241 );
3242
32003243 it.effect.each([
32013244 { recovered: false, expected: "Claude usage limit reached" },
32023245 { recovered: true, expected: "Claude gave up after repeated API errors" },
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+10-5
......@@ -7676,11 +7676,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
76767676 (message.terminal_reason == null ||
76777677 message.terminal_reason === "api_error" ||
76787678 message.terminal_reason === "blocking_limit");
7679 const failureHint =
7680 context.authenticationFailureMessage ??
7681 (usageLimited
7682 ? "Claude usage limit reached. Send the message again once the limit resets."
7683 : undefined);
7679 // The CLI also labels a 403 authentication_failed. Logging in again
7680 // can't grant a permission, so show the CLI's own text instead.
7681 const permissionDenied =
7682 message.subtype === "success" && message.api_error_status === 403;
7683 const failureHint = permissionDenied
7684 ? message.result
7685 : (context.authenticationFailureMessage ??
7686 (usageLimited
7687 ? "Claude usage limit reached. Send the message again once the limit resets."
7688 : undefined));
76847689 const resetTimes = Array.from(context.rateLimitResetTimes.values());
76857690 const resetAt =
76867691 resetTimes.length > 0 && resetTimes.every((time) => time !== null)