Files
shockbot/utils/providerErrors.test.ts
T
Colin McDonnell b9383bbcfd action: center provider-error log excerpt on the matched line (closes #703)
the `» provider error detected (...)` excerpt was `chunk.substring(0, 500)`
— the head of whatever stderr buffer node delivered. on big writes that's
the front of an mcp tool-schema dump, not the matched error text. label
was correct (regex.test on the whole chunk), excerpt was misleading.

introduce findProviderErrorMatch(text) that returns { label, excerpt }
where excerpt is a windowed slice centered on the regex match index:
the matched line plus 1 line before and 2 lines after, hard-capped at
600 bytes. detectProviderError stays as a thin wrapper for label-only
callers. both opencode and claude harnesses log match.excerpt instead
of chunk.substring(0, 500).

regression tests cover the multi-line buffer case, surrounding-line
context, byte-cap fallback to matched-line-only, and head truncation
of a single oversize line.
2026-05-14 03:59:45 +00:00

227 lines
9.7 KiB
TypeScript

import {
detectProviderError,
findProviderErrorMatch,
isRouterKeylimitExhaustedError,
} from "./providerErrors.ts";
describe("detectProviderError", () => {
describe("false positives previously seen in production", () => {
it("returns null for commit SHAs containing 429", () => {
expect(detectProviderError("hash=7a46d89f505b36df49b4f54429daffa1a9459b11")).toBeNull();
expect(detectProviderError("commit f609cc89e84596ab125d60dac568bfb2ef398396 429")).toBeNull();
});
it("classifies 401 + x-ratelimit-* headers as auth, not rate-limited", () => {
// OpenRouter 401 responses bundle `x-ratelimit-*` rate-limit headers
// alongside the auth error. the auth patterns must win — pre-fix this
// got tagged as `rate limited` because of the loose `\brate[_ ]limit`
// match against header names like `ratelimit-limit-requests`. note: in
// OpenRouter's actual format the header name is `ratelimit` (one word),
// but the dumped JSON sometimes contains `rate-limit` separators too.
const stderr = JSON.stringify({
error: { name: "APIError", statusCode: 401, message: "Invalid authentication credentials" },
headers: {
"x-ratelimit-limit-requests": 50,
"x-ratelimit-remaining-requests": 49,
"x-ratelimit-reset-tokens": "2025-01-01T00:00:00Z",
},
});
expect(detectProviderError(stderr)).toBe("auth error (401)");
});
it("returns null for INTERNAL_SERVER_ERROR substring", () => {
expect(detectProviderError("HTTP/1.1 500 INTERNAL_SERVER_ERROR")).toBeNull();
expect(detectProviderError("expected: not INTERNAL_SERVER_ERROR")).toBeNull();
});
it("returns null for INTERNALS substring", () => {
expect(detectProviderError("debugging INTERNALS of the parser")).toBeNull();
});
});
describe("auth errors", () => {
it("detects 401 / 403 status codes as auth errors", () => {
expect(detectProviderError('{"statusCode": 401}')).toBe("auth error (401)");
expect(detectProviderError('{"statusCode": 403}')).toBe("auth error (403)");
expect(detectProviderError("status_code: 401")).toBe("auth error (401)");
});
it("detects OpenRouter 'User not found' (disabled/invalid key)", () => {
// bare `"code":401` lacks a status-key prefix so the 401 status pattern
// intentionally doesn't fire; the User-not-found pattern catches it.
expect(detectProviderError('{"error":{"message":"User not found","code":401}}')).toBe(
"auth error (invalid/disabled key)"
);
expect(detectProviderError("APIError: User not found.")).toBe(
"auth error (invalid/disabled key)"
);
});
it("detects 'Invalid authentication' phrasing", () => {
expect(detectProviderError("Invalid authentication credentials")).toBe(
"auth error (invalid credentials)"
);
});
it("detects 'No auth credentials found' phrasing", () => {
expect(detectProviderError("AI_APICallError: No auth credentials found")).toBe(
"auth error (missing credentials)"
);
});
});
describe("real provider errors", () => {
it("detects 429 only when adjacent to a status key", () => {
expect(detectProviderError('{"statusCode": 429}')).toBe("rate limited (429)");
expect(detectProviderError('{"status_code": 429, "message": "..."}')).toBe(
"rate limited (429)"
);
expect(detectProviderError("http_status: 429")).toBe("rate limited (429)");
expect(detectProviderError("status=429")).toBe("rate limited (429)");
});
it("detects rate_limit_error and rate_limit_exceeded", () => {
expect(detectProviderError('{"type":"rate_limit_error"}')).toBe("rate limited");
expect(detectProviderError("rate_limit_exceeded")).toBe("rate limited");
expect(detectProviderError("plain rate limit reached")).toBe("rate limited");
});
it("detects rate-limit phrasing with trailing inflection", () => {
expect(detectProviderError("Error: rate limited by provider")).toBe("rate limited");
expect(detectProviderError("rate limits exceeded for this key")).toBe("rate limited");
});
it("detects RESOURCE_EXHAUSTED", () => {
expect(detectProviderError('"status": "RESOURCE_EXHAUSTED"')).toBe("quota exhausted");
});
it("detects gRPC INTERNAL status as a whole word", () => {
expect(detectProviderError('"status": "INTERNAL"')).toBe("provider internal error");
});
it("detects UNAVAILABLE as a whole word", () => {
expect(detectProviderError('"status": "UNAVAILABLE"')).toBe("provider unavailable");
});
it("detects 500 / 503 only when adjacent to a status key", () => {
expect(detectProviderError('"statusCode": 500')).toBe("provider 500 error");
expect(detectProviderError('"statusCode": 503')).toBe("provider unavailable (503)");
expect(detectProviderError("v1.503.0 release notes")).toBeNull();
});
it("detects quota and zero-quota responses", () => {
expect(detectProviderError('"message": "quota exceeded"')).toBe("quota error");
expect(detectProviderError('{"code":"insufficient_quota"}')).toBe("quota error");
expect(detectProviderError('"error":"quota_exceeded"')).toBe("quota error");
expect(detectProviderError('{"reason":"quotaExceeded"}')).toBe("quota error");
expect(detectProviderError('{"limit": 0, "remaining": 0}')).toBe("zero quota");
expect(detectProviderError('"time_limit": 0')).toBeNull();
});
});
});
describe("findProviderErrorMatch", () => {
// regression for issue #703: when stderr arrives as a multi-KB buffer
// (mcp tool-schema dump + the actual error message), the old
// `chunk.substring(0, 500)` excerpt showed the head of the buffer
// (schema) instead of the matched error text. the windowed excerpt
// must center on the matched line.
it("excerpt centers on the matched line, not the head of the buffer", () => {
const schemaDump =
"{".repeat(2000) +
'"name":"pullfrog_create_pull_request_review","description":"Submit a review..."';
const errorLine = "ERROR 2026-05-13 service=session error=rate_limit_exceeded retry-after=30";
const chunk = `${schemaDump}\n${errorLine}\ncaller stack at handler.ts:42`;
const match = findProviderErrorMatch(chunk);
expect(match).not.toBeNull();
expect(match?.label).toBe("rate limited");
expect(match?.excerpt).toContain("rate_limit_exceeded");
expect(match?.excerpt).toContain("retry-after=30");
expect(match?.excerpt).not.toContain("pullfrog_create_pull_request_review");
});
it("includes a small surrounding-line window for stack-trace context", () => {
const chunk =
"» about to call session.processor\n" +
"ERROR rate_limit_exceeded for key=abc\n" +
"at handler.ts:42\n" +
"at runtime.ts:88";
const match = findProviderErrorMatch(chunk);
expect(match?.excerpt).toContain("about to call session.processor");
expect(match?.excerpt).toContain("rate_limit_exceeded");
expect(match?.excerpt).toContain("handler.ts:42");
expect(match?.excerpt).toContain("runtime.ts:88");
});
it("falls back to the matched line alone when adjacent lines are huge", () => {
const giantPrefix = "x".repeat(5000);
const errorLine = '"statusCode": 429, "message": "slow down"';
const giantSuffix = "y".repeat(5000);
const chunk = `${giantPrefix}\n${errorLine}\n${giantSuffix}`;
const match = findProviderErrorMatch(chunk);
expect(match?.label).toBe("rate limited (429)");
expect(match?.excerpt).toBe(errorLine);
});
it("head-truncates the matched line if it alone exceeds the byte cap", () => {
const padding = "z".repeat(700);
const chunk = `${padding} "statusCode": 429 ${padding}`;
const match = findProviderErrorMatch(chunk);
expect(match?.label).toBe("rate limited (429)");
expect(match?.excerpt.length).toBeLessThanOrEqual(600);
});
it("returns null when no pattern matches", () => {
expect(findProviderErrorMatch("just some normal log line\nnothing wrong here")).toBeNull();
});
});
describe("isRouterKeylimitExhaustedError", () => {
it("matches the canonical OpenRouter mid-run error", () => {
expect(
isRouterKeylimitExhaustedError(
"APIError: This request requires more credits, or fewer max_tokens. " +
"You requested up to 32000 tokens, but can only afford 22800. " +
"To increase, visit https://openrouter.ai/settings/keys and create a key with a higher total limit"
)
).toBe(true);
});
it("matches the 'requires more credits' phrasing on its own", () => {
expect(
isRouterKeylimitExhaustedError("This request requires more credits, or fewer max_tokens.")
).toBe(true);
});
it("matches the 'requested up to ... can only afford' phrasing on its own", () => {
expect(
isRouterKeylimitExhaustedError("You requested up to 8000 tokens but can only afford 1234")
).toBe(true);
});
it("does not match generic out-of-credit text", () => {
expect(isRouterKeylimitExhaustedError("Your account has insufficient credits")).toBe(false);
expect(isRouterKeylimitExhaustedError("rate_limit_exceeded")).toBe(false);
expect(isRouterKeylimitExhaustedError('{"limit": 0}')).toBe(false);
});
it("does not match unrelated mentions of max_tokens", () => {
expect(isRouterKeylimitExhaustedError("max_tokens parameter must be a positive integer")).toBe(
false
);
});
it("matches across newlines (defends against upstream wrapping/reformatting)", () => {
expect(
isRouterKeylimitExhaustedError(
"APIError: This request requires more credits, or\nfewer max_tokens. You requested up to 32000 tokens"
)
).toBe(true);
expect(
isRouterKeylimitExhaustedError("You requested up to 32000 tokens,\nbut can only afford 22800")
).toBe(true);
});
});