Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
8 changes: 8 additions & 0 deletions src/adapters/cursor/cursor-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,14 @@ export function classifyCursorError(message: string, sizeContext?: CursorSizeCon
lower.includes("server is busy")
) return "Cursor server overloaded";

// gRPC FAILED_PRECONDITION is deterministic and non-retryable (unlike UNAVAILABLE):
// the backend rejected the call because the account/plan state does not allow it —
// seen live when a plan-gated model (e.g. claude-fable-5) runs on a plan without it.
// Leaving it as "Cursor upstream error" (502) made clients retry it as overload.
if (lower.includes("failed_precondition") || lower.includes("failed precondition")) {
return "Cursor invalid request";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

if (
lower.includes("invalid") ||
lower.includes("not found") ||
Expand Down
17 changes: 14 additions & 3 deletions src/claude/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* - errors: {type:"error", error:{type,message}}; may arrive mid-stream after HTTP 200.
*/
import { createHash } from "node:crypto";
import { httpStatusFromTerminalError } from "../lib/errors";
import { isTransientUpstreamStatus } from "../lib/upstream-retry";
import {
isTranslatorBudgetExceededError,
Expand Down Expand Up @@ -555,9 +556,19 @@ export function responsesSseToAnthropicSse(
}
const status = code === "translation_buffer_limit"
? 413
: typeof error.status === "number" ? error.status : 500;
// status-absent response.failed (relaySseWithFailedTail synthetic tail) defaults
// to 500, which is in the transient set — the mid-stream reset shape maps to
: typeof error.status === "number"
? error.status
// Internal response.failed envelopes carry the classified {type, code, message}
// but no numeric status. Derive it with the same mapping /api/logs uses so a
// classified 429/401/400 reaches Claude Code as its real Anthropic error type
// instead of being masked as retryable overload.
: httpStatusFromTerminalError({
type: typeof error.type === "string" ? error.type : undefined,
code: typeof error.code === "string" ? error.code : null,
message,
});
// Unclassified status-absent response.failed (relaySseWithFailedTail synthetic
// tail) still lands on a transient 5xx here — the mid-stream reset shape maps to
// overloaded_error by design.
fail(
status,
Expand Down
8 changes: 8 additions & 0 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,14 @@ export function httpStatusFromTerminalError(error: {
) return 403;
if (error.type === "insufficient_quota" || error.code === "insufficient_quota") return 429;
if (error.type === "server_error" && error.code === "server_is_overloaded") return 503;
// A structured generic upstream/server classification outranks message inference.
// classifyError assigns `server_error` + `upstream_server_error` to every 5xx it sees,
// so the class is authoritative: the provider already told us this was a server failure.
// Falling through to the message heuristics below inverts that, because an upstream 500
// whose text happens to contain "malformed" or "invalid request" is read as a client
// error and returns 400. Claude Code then stops retrying a failure that was retryable,
// and the caller sees invalid_request_error for an upstream outage.
if (error.type === "server_error" || error.code === "upstream_server_error") return 502;
// Client-closed messages often arrive as invalid_request_error after classifyError; check message
// before treating every invalid_request_error as HTTP 400.
const message = error.message ?? "";
Expand Down
105 changes: 105 additions & 0 deletions tests/claude-outbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,111 @@ describe("claude outbound SSE", () => {
});
});

// Internal response.failed envelopes carry the classified {type, code, message} but no
// numeric status (adapterFailureFromEvent drops httpStatus at the wire boundary). The
// classified status must be derived the same way /api/logs derives it, or every classified
// failure is masked as retryable overloaded_error — a Cursor plan/quota 429 surfaced in
// Claude Code as "Repeated 529 Overloaded errors".
test("failed with classified rate_limit_error but NO numeric status -> rate_limit_error, not overloaded", async () => {
const upstream = [
sse("response.created", { response: {} }),
sse("response.failed", {
response: {
status: "failed",
error: { type: "rate_limit_error", code: "rate_limit_exceeded", message: "Cursor rate limit exceeded: quota exhausted" },
},
}),
].join("");
const events = await collectEvents(responsesSseToAnthropicSse(streamFrom(upstream), "m"));
expect(events.at(-1)!.data).toEqual({
type: "error",
error: { type: "rate_limit_error", message: "Cursor rate limit exceeded: quota exhausted" },
});
});

test("failed with classified authentication_error but NO numeric status -> authentication_error, not overloaded", async () => {
const upstream = [
sse("response.created", { response: {} }),
sse("response.failed", {
response: {
status: "failed",
error: { type: "authentication_error", code: "invalid_api_key", message: "Cursor authentication failed: expired token" },
},
}),
].join("");
const events = await collectEvents(responsesSseToAnthropicSse(streamFrom(upstream), "m"));
expect(events.at(-1)!.data).toEqual({
type: "error",
error: { type: "authentication_error", message: "Cursor authentication failed: expired token" },
});
});

test("failed with classified invalid_request_error but NO numeric status -> invalid_request_error, not overloaded", async () => {
const upstream = [
sse("response.created", { response: {} }),
sse("response.failed", {
response: {
status: "failed",
error: { type: "invalid_request_error", code: "context_length_exceeded", message: "Cursor context limit exceeded" },
},
}),
].join("");
const events = await collectEvents(responsesSseToAnthropicSse(streamFrom(upstream), "m"));
expect(events.at(-1)!.data).toEqual({
type: "error",
error: { type: "invalid_request_error", message: "Cursor context limit exceeded" },
});
});

test("failed with classified server overload but NO numeric status -> still overloaded_error", async () => {
const upstream = [
sse("response.created", { response: {} }),
sse("response.failed", {
response: {
status: "failed",
error: { type: "server_error", code: "server_is_overloaded", message: "Cursor server overloaded: unavailable" },
},
}),
].join("");
const events = await collectEvents(responsesSseToAnthropicSse(streamFrom(upstream), "m"));
expect(events.at(-1)!.data).toEqual({
type: "error",
error: { type: "overloaded_error", message: "Cursor server overloaded: unavailable" },
});
});

// Deriving the status from the classified payload is only safe if a STRUCTURED server
// class outranks the message heuristics. `httpStatusFromTerminalError` recognized only
// `server_error` + `server_is_overloaded`, so a generic `upstream_server_error` fell
// through to `inferHttpStatusFromAdapterMessage`. An upstream 5xx whose text happens to
// contain "malformed" or "invalid request" then returned 400, and Claude Code stopped
// retrying a genuinely retryable upstream failure — the exact inversion this PR set out
// to fix, reintroduced one layer down. classifyError assigns `upstream_server_error` to
// every 5xx it sees, so the class is authoritative over the words in the message.
test("generic upstream_server_error with client-sounding text stays a transient server failure", async () => {
const upstream = [
sse("response.created", { response: {} }),
sse("response.failed", {
response: {
status: "failed",
error: {
type: "server_error",
code: "upstream_server_error",
message: "upstream stream produced malformed tool call arguments",
},
},
}),
].join("");
const events = await collectEvents(responsesSseToAnthropicSse(streamFrom(upstream), "m"));
expect(events.at(-1)!.data).toEqual({
type: "error",
error: {
type: "overloaded_error",
message: "upstream stream produced malformed tool call arguments",
},
});
});

test("internal reader exception stays api_error (not promoted to overloaded)", async () => {
const boom = new ReadableStream<Uint8Array>({
start(controller) {
Expand Down
10 changes: 10 additions & 0 deletions tests/cursor-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isCursorInvalidArgumentError,
safeCursorErrorMessage,
} from "../src/adapters/cursor/cursor-errors";
import { inferHttpStatusFromAdapterMessage } from "../src/lib/errors";

describe("classifyCursorError", () => {
test("rate limit and resource exhaustion stay distinct", () => {
Expand Down Expand Up @@ -59,6 +60,15 @@ describe("classifyCursorError", () => {
expect(classifyCursorError("invalid request: malformed tool schema")).toBe("Cursor invalid request");
});

test("failed_precondition is a deterministic non-retryable rejection, not overload", () => {
// Live evidence: a plan-gated model (claude-fable-5) invoked on a plan without it
// returns "Cursor Connect error failed_precondition: Error". gRPC FAILED_PRECONDITION
// is non-retryable by definition; as "Cursor upstream error" (502) clients retried it
// as overload, and the Claude inbound surfaced it as a misleading 529.
expect(classifyCursorError("Cursor Connect error failed_precondition: Error")).toBe("Cursor invalid request");
expect(inferHttpStatusFromAdapterMessage("Cursor invalid request: Cursor Connect error failed_precondition: Error")).toBe(400);
});

test("timeout / deadline", () => {
expect(classifyCursorError("Cursor transport timed out before first response")).toBe("Cursor request timed out");
expect(classifyCursorError("deadline exceeded")).toBe("Cursor request timed out");
Expand Down
Loading