feat(error-rules): provider-specific error classification with scope (#3370)

Integrated into release/v3.8.15. PR has genuine value beyond #3369: (1) getProviderErrorRuleMatch now accepts native Headers objects from fetch(); (2) checkFallbackError also uses the provider rule registry — the real end-to-end wiring in the combo fallback path; (3) S4 end-to-end test proving the wiring fires. Merge commit on contributor branch resolved the add/add conflict by taking the #3370 version throughout.
This commit is contained in:
Hernan Javier Ardila Sanchez
2026-06-07 16:13:29 +02:00
committed by GitHub
parent 73a1549405
commit c522761ce5
3 changed files with 56 additions and 4 deletions

View File

@@ -125,13 +125,19 @@ export const providerRuleRegistry = new Map<string, ProviderErrorRule[]>([
export function getProviderErrorRuleMatch(
provider: string | null | undefined,
status: number,
headers: Record<string, string> | null | undefined,
headers: Headers | Record<string, string> | null | undefined,
body?: unknown
): ProviderErrorRuleMatch | null {
if (!provider) return null;
const rules = providerRuleRegistry.get(provider);
if (!rules) return null;
const safeHeaders = headers ?? {};
// Normalize headers: accept either a `Headers` object (from `fetch()`) or
// a plain record. Provider rules access headers via plain object indexing.
const safeHeaders: Record<string, string> = !headers
? {}
: typeof (headers as Headers).get === "function"
? Object.fromEntries((headers as Headers).entries())
: (headers as Record<string, string>);
for (const rule of rules) {
const match = rule.match({ status, headers: safeHeaders, body });
if (match) return match;

View File

@@ -1391,7 +1391,21 @@ export function checkFallbackError(
: findMatchingErrorRule(status, errorStr);
if (configuredRule) {
if (configuredRule.backoff) {
return buildRetryableFallback(configuredRule.reason ?? classifyError(status, errorStr));
// Provider-specific rules in `providerRuleRegistry` are MORE SPECIFIC
// than the configured (global) rule, so we check them first. If a
// provider rule matches, it overrides the configured rule's reason
// (e.g. Opencode's `x-ratelimit-remaining-requests: 0` overrides
// 429 → RATE_LIMIT_EXCEEDED). We do NOT call the full `classifyError`
// here because its global status fallback would otherwise override
// specific configured reasons (e.g. 503 → SERVER_ERROR would be
// shadowed by 503 → MODEL_CAPACITY).
const providerMatch = provider
? getProviderErrorRuleMatch(provider, status, headers, structuredError ?? null)
: null;
const reason = providerMatch
? providerMatch.reason
: (configuredRule.reason ?? RateLimitReason.UNKNOWN);
return buildRetryableFallback(reason);
}
const cooldownMs = configuredRule.cooldownMs ?? 0;
return {

View File

@@ -14,7 +14,7 @@ import assert from "node:assert/strict";
* - Anything else: falls back to global ERROR_RULES.
*/
const { classifyError } = await import(
const { classifyError, checkFallbackError } = await import(
"../../open-sse/services/accountFallback.ts"
);
const { RateLimitReason } = await import(
@@ -86,3 +86,35 @@ test("S3: Regression — provider with no rules falls back to global ERROR_RULES
const reasonNoCtx = classifyError(429, "rate limit reached");
assert.equal(reasonNoCtx, RateLimitReason.RATE_LIMIT_EXCEEDED);
});
test("S4: End-to-end — checkFallbackError forwards provider+headers to classifyError", () => {
// The wiring test: when combo.ts calls checkFallbackError with provider=opencode
// and headers containing x-ratelimit-remaining-requests: 0, the reason must be
// QUOTA_EXHAUSTED (not RATE_LIMIT_EXCEEDED). This proves the registry is ACTIVE
// in the production fallback path, not just callable in isolation.
//
// Simulate what combo.ts:3849 does — it passes provider, headers, and structuredError.
// For Opencode with account-wide quota exhausted, the fallback should signal
// quota_exhausted so the combo router skips remaining targets from the same provider.
const result = checkFallbackError(
429,
"rate limit reached", // generic body that would normally be RATE_LIMIT_EXCEEDED
0, // backoffLevel
null, // model
"opencode", // provider
{ "x-ratelimit-remaining-requests": "0" }, // headers
null, // profileOverride
null // structuredError
);
assert.equal(
result.reason,
RateLimitReason.QUOTA_EXHAUSTED,
"checkFallbackError must forward provider+headers to classifyError so the Opencode quota rule fires"
);
assert.equal(
result.shouldFallback,
true,
"quota_exhausted must trigger fallback to the next provider"
);
});