FreeLLMAPI / server /src /__tests__ /routes /proxy-retry.test.ts
Nryn215's picture
Upload folder using huggingface_hub
077865a verified
Raw
History Blame Contribute Delete
8.3 kB
import { describe, it, expect } from 'vitest';
import { isRetryableError, isPaymentRequiredError, isModelNotFoundError, isModelAccessForbiddenError } from '../../routes/proxy.js';
describe('isModelAccessForbiddenError (403 model-not-on-tier, drives whole-model skip — issue #256)', () => {
it('flags a 403 reaching the proxy by message or attached status', () => {
// GitHub Models / Cloudflare 403 a model the key's free tier can't reach.
expect(isModelAccessForbiddenError(new Error('GitHub Models API error 403: Model not available on your plan'))).toBe(true);
expect(isModelAccessForbiddenError(new Error('Cloudflare API error 403: this model requires a subscription'))).toBe(true);
expect(isModelAccessForbiddenError(new Error('Forbidden'))).toBe(true);
// #261 attaches the upstream status to the thrown error; honor it even if
// the message phrasing omits the code.
expect(isModelAccessForbiddenError(Object.assign(new Error('access denied'), { status: 403 }))).toBe(true);
});
it('does not flag rate limits, 404s, or payment errors', () => {
expect(isModelAccessForbiddenError(new Error('429 Too Many Requests'))).toBe(false);
expect(isModelAccessForbiddenError(new Error('OpenRouter API error 404: Provider returned error'))).toBe(false);
expect(isModelAccessForbiddenError(new Error('HuggingFace Router API error 402: Payment required'))).toBe(false);
});
});
describe('isModelNotFoundError (drives whole-model skip within a request)', () => {
it('flags 404 / not-found / no-endpoints phrasings', () => {
expect(isModelNotFoundError(new Error('OpenRouter API error 404: Provider returned error'))).toBe(true);
expect(isModelNotFoundError(new Error('Model not found'))).toBe(true);
expect(isModelNotFoundError(new Error('No endpoints found for openrouter/minimax/minimax-m2.5:free'))).toBe(true);
});
it('does not flag rate limits, 5xx, or payment errors', () => {
expect(isModelNotFoundError(new Error('429 Too Many Requests'))).toBe(false);
expect(isModelNotFoundError(new Error('503 Service Unavailable'))).toBe(false);
expect(isModelNotFoundError(new Error('HuggingFace Router API error 402: Payment required'))).toBe(false);
});
});
describe('isRetryableError', () => {
describe('413 Payload Too Large', () => {
it('treats explicit "413" in the error message as retryable', () => {
expect(isRetryableError(new Error('GitHub Models API error 413: Request body too large'))).toBe(true);
expect(isRetryableError(new Error('Cloudflare API error 413: Payload Too Large'))).toBe(true);
});
it('treats common 413 phrasings (no status code) as retryable', () => {
expect(isRetryableError(new Error('Payload Too Large'))).toBe(true);
expect(isRetryableError(new Error('Request body too large for this model'))).toBe(true);
expect(isRetryableError(new Error('Request entity too large'))).toBe(true);
expect(isRetryableError(new Error('Content too large'))).toBe(true);
});
});
describe('404 model removed / not found (the bug #66 fixes)', () => {
it('treats explicit "404" in the error message as retryable', () => {
expect(isRetryableError(new Error('OpenRouter API error 404: Provider returned error'))).toBe(true);
expect(isRetryableError(new Error('Groq API error 404: model not found'))).toBe(true);
});
it('catches OpenRouter\'s "No endpoints found" phrasing for deprecated models', () => {
expect(isRetryableError(new Error('No endpoints found for openrouter/minimax/minimax-m2.5:free'))).toBe(true);
});
it('catches bare "not found" phrasing (any provider, any case)', () => {
expect(isRetryableError(new Error('Model not found'))).toBe(true);
expect(isRetryableError(new Error('The requested model was not found'))).toBe(true);
});
});
describe('provider tool-call generation 400s fail over (#168)', () => {
// Groq (and every other openai-compat provider) throws its errors as
// `${name} API error ${status}: ${msg}`, so a tool-call-generation failure
// surfaces as "Groq API error 400: Failed to call a function...". That
// matches the "api error 400" rule, so it's ALREADY retryable and fails
// over to the next provider — #168 is covered by existing behavior.
it('treats a Groq failed_generation 400 as retryable', () => {
expect(isRetryableError(new Error(
"Groq API error 400: Failed to call a function. Please adjust your prompt. See 'failed_generation' for more details.",
))).toBe(true);
});
it('treats any openai-compat "API error 400" as retryable (one provider rejects params another accepts)', () => {
expect(isRetryableError(new Error('Cerebras API error 400: tool schema not supported'))).toBe(true);
});
it('but a bare validation "400 Bad Request" (our own schema) is still NOT retryable', () => {
expect(isRetryableError(new Error('400 Bad Request'))).toBe(false);
});
});
describe('403 model not on this key\'s tier fails over instead of 502 (issue #256)', () => {
it('treats a 403 from GitHub Models / Cloudflare as retryable', () => {
expect(isRetryableError(new Error('GitHub Models API error 403: Model not available on your plan'))).toBe(true);
expect(isRetryableError(new Error('Cloudflare API error 403: this model requires a subscription'))).toBe(true);
});
it('treats a bare "Forbidden" / attached 403 status as retryable', () => {
expect(isRetryableError(new Error('Forbidden'))).toBe(true);
expect(isRetryableError(Object.assign(new Error('access denied'), { status: 403 }))).toBe(true);
});
it('still treats a bare 400 validation error as non-retryable', () => {
expect(isRetryableError(new Error('400 Bad Request'))).toBe(false);
});
});
describe('402 Payment Required out-of-credits fails over (graceful degradation)', () => {
it('treats a HuggingFace Router 402 as retryable (same model lives on other providers)', () => {
expect(isRetryableError(new Error('HuggingFace Router API error 402: Payment required'))).toBe(true);
});
it('catches common out-of-credits phrasings', () => {
expect(isRetryableError(new Error('Payment Required'))).toBe(true);
expect(isRetryableError(new Error('You exceeded your current quota: insufficient_quota'))).toBe(true);
expect(isRetryableError(new Error('Insufficient credit for this request'))).toBe(true);
expect(isRetryableError(new Error('Insufficient balance'))).toBe(true);
});
it('isPaymentRequiredError flags 402 (drives the long bench) but not a 429', () => {
expect(isPaymentRequiredError(new Error('HuggingFace Router API error 402: Payment required'))).toBe(true);
expect(isPaymentRequiredError(new Error('429 Too Many Requests'))).toBe(false);
expect(isPaymentRequiredError(new Error('503 Service Unavailable'))).toBe(false);
});
});
describe('existing categories still classify correctly', () => {
it('429 / rate limits are retryable', () => {
expect(isRetryableError(new Error('429 Too Many Requests'))).toBe(true);
expect(isRetryableError(new Error('rate limit exceeded'))).toBe(true);
expect(isRetryableError(new Error('quota exhausted'))).toBe(true);
});
it('5xx and network errors are retryable', () => {
expect(isRetryableError(new Error('503 Service Unavailable'))).toBe(true);
expect(isRetryableError(new Error('500 Internal Server Error'))).toBe(true);
expect(isRetryableError(new Error('ETIMEDOUT'))).toBe(true);
expect(isRetryableError(new Error('ECONNREFUSED'))).toBe(true);
});
it('401 / bare-400 auth & validation errors are NOT retryable', () => {
expect(isRetryableError(new Error('401 Unauthorized'))).toBe(false);
expect(isRetryableError(new Error('400 Bad Request'))).toBe(false);
expect(isRetryableError(new Error('Invalid API key'))).toBe(false);
// 403 is deliberately NOT here anymore: a request-time 403 on a key that
// passed validateKey is a model-not-on-tier gate, so it fails over to the
// next model rather than 502-ing the request (issue #256). The 403 cases
// are covered in the dedicated describe block above.
});
});
});