mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
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:
committed by
GitHub
parent
73a1549405
commit
c522761ce5
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user