0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
294 lines
9.9 KiB
TypeScript
294 lines
9.9 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
getProviderResponseHeaders,
|
|
isProviderResponseRateLimited,
|
|
isTransientConnectionError,
|
|
} from '../../src/scheduler/types';
|
|
|
|
import type { ProviderResponse } from '../../src/types/providers';
|
|
|
|
describe('isProviderResponseRateLimited', () => {
|
|
describe('HTTP status detection', () => {
|
|
it('should detect 429 status in metadata.http.status', () => {
|
|
const result: ProviderResponse = {
|
|
output: 'error',
|
|
metadata: { http: { status: 429, statusText: 'Too Many Requests', headers: {} } },
|
|
};
|
|
expect(isProviderResponseRateLimited(result, undefined)).toBe(true);
|
|
});
|
|
|
|
it('should not flag other status codes', () => {
|
|
const result: ProviderResponse = {
|
|
output: 'success',
|
|
metadata: { http: { status: 200, statusText: 'OK', headers: {} } },
|
|
};
|
|
expect(isProviderResponseRateLimited(result, undefined)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Error field detection', () => {
|
|
it('should detect 429 in result.error string', () => {
|
|
const result: ProviderResponse = {
|
|
output: '',
|
|
error: 'HTTP 429: Too Many Requests',
|
|
};
|
|
expect(isProviderResponseRateLimited(result, undefined)).toBe(true);
|
|
});
|
|
|
|
it('should detect "rate limit" in result.error (case insensitive)', () => {
|
|
const result: ProviderResponse = {
|
|
output: '',
|
|
error: 'Rate Limit Exceeded',
|
|
};
|
|
expect(isProviderResponseRateLimited(result, undefined)).toBe(true);
|
|
});
|
|
|
|
it('should handle undefined result.error gracefully', () => {
|
|
const result: ProviderResponse = {
|
|
output: 'success',
|
|
};
|
|
expect(isProviderResponseRateLimited(result, undefined)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Thrown error detection', () => {
|
|
it('should detect 429 in error.message', () => {
|
|
const error = new Error('Request failed with status 429');
|
|
expect(isProviderResponseRateLimited(undefined, error)).toBe(true);
|
|
});
|
|
|
|
it('should detect "rate limit" in error.message (case insensitive)', () => {
|
|
const error = new Error('API rate limit exceeded');
|
|
expect(isProviderResponseRateLimited(undefined, error)).toBe(true);
|
|
});
|
|
|
|
it('should detect "too many requests" in error.message', () => {
|
|
const error = new Error('Too many requests, please slow down');
|
|
expect(isProviderResponseRateLimited(undefined, error)).toBe(true);
|
|
});
|
|
|
|
it('should handle error with undefined message gracefully', () => {
|
|
const error = new Error('placeholder');
|
|
(error as { message: string | undefined }).message = undefined;
|
|
expect(isProviderResponseRateLimited(undefined, error)).toBe(false);
|
|
});
|
|
|
|
it('should handle undefined error gracefully', () => {
|
|
expect(isProviderResponseRateLimited(undefined, undefined)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Combined detection', () => {
|
|
it('should detect rate limit in result even with error present', () => {
|
|
const result: ProviderResponse = {
|
|
output: '',
|
|
error: '429 rate limited',
|
|
};
|
|
const error = new Error('Some other error');
|
|
expect(isProviderResponseRateLimited(result, error)).toBe(true);
|
|
});
|
|
|
|
it('should detect rate limit in error even with result present', () => {
|
|
const result: ProviderResponse = {
|
|
output: 'success',
|
|
};
|
|
const error = new Error('Rate limit exceeded');
|
|
expect(isProviderResponseRateLimited(result, error)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Edge cases', () => {
|
|
it('should not false-positive on similar strings', () => {
|
|
const result: ProviderResponse = {
|
|
output: 'The rate of change is 42.9%',
|
|
error: 'Rating limit exceeded expectations',
|
|
};
|
|
// "Rating limit" is not "rate limit" - should not match
|
|
// But actually... "rating limit" contains "rate" and "limit" separately
|
|
// Let me check the actual logic - it uses .includes('rate limit')
|
|
expect(isProviderResponseRateLimited(result, undefined)).toBe(false);
|
|
});
|
|
|
|
it('should handle empty result object', () => {
|
|
const result: ProviderResponse = {};
|
|
expect(isProviderResponseRateLimited(result, undefined)).toBe(false);
|
|
});
|
|
|
|
it('should handle result with nested undefined metadata', () => {
|
|
const result: ProviderResponse = {
|
|
output: 'success',
|
|
metadata: {},
|
|
};
|
|
expect(isProviderResponseRateLimited(result, undefined)).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getProviderResponseHeaders', () => {
|
|
it('should extract headers from metadata.http.headers', () => {
|
|
const result: ProviderResponse = {
|
|
output: 'success',
|
|
metadata: {
|
|
http: {
|
|
status: 200,
|
|
statusText: 'OK',
|
|
headers: {
|
|
'x-ratelimit-remaining': '10',
|
|
'x-ratelimit-limit': '100',
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const headers = getProviderResponseHeaders(result);
|
|
expect(headers).toEqual({
|
|
'x-ratelimit-remaining': '10',
|
|
'x-ratelimit-limit': '100',
|
|
});
|
|
});
|
|
|
|
it('should extract headers from metadata.headers as fallback', () => {
|
|
const result: ProviderResponse = {
|
|
output: 'success',
|
|
metadata: {
|
|
headers: {
|
|
'retry-after': '60',
|
|
},
|
|
},
|
|
};
|
|
const headers = getProviderResponseHeaders(result);
|
|
expect(headers).toEqual({
|
|
'retry-after': '60',
|
|
});
|
|
});
|
|
|
|
it('should prefer metadata.http.headers over metadata.headers', () => {
|
|
const result: ProviderResponse = {
|
|
output: 'success',
|
|
metadata: {
|
|
http: {
|
|
status: 200,
|
|
statusText: 'OK',
|
|
headers: { 'x-from-http': 'true' },
|
|
},
|
|
headers: { 'x-from-meta': 'true' },
|
|
},
|
|
};
|
|
const headers = getProviderResponseHeaders(result);
|
|
expect(headers).toEqual({ 'x-from-http': 'true' });
|
|
});
|
|
|
|
it('should return undefined for result without headers', () => {
|
|
const result: ProviderResponse = {
|
|
output: 'success',
|
|
};
|
|
expect(getProviderResponseHeaders(result)).toBeUndefined();
|
|
});
|
|
|
|
it('should return undefined for undefined result', () => {
|
|
expect(getProviderResponseHeaders(undefined)).toBeUndefined();
|
|
});
|
|
|
|
it('should return undefined for result with empty metadata', () => {
|
|
const result: ProviderResponse = {
|
|
output: 'success',
|
|
metadata: {},
|
|
};
|
|
expect(getProviderResponseHeaders(result)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('isTransientConnectionError', () => {
|
|
it('should detect bad record mac errors', () => {
|
|
expect(isTransientConnectionError(new Error('bad record mac'))).toBe(true);
|
|
});
|
|
|
|
it('should detect EPROTO errors', () => {
|
|
expect(isTransientConnectionError(new Error('write EPROTO 00000000:error:0A000126'))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('should detect ECONNRESET errors', () => {
|
|
expect(isTransientConnectionError(new Error('ECONNRESET'))).toBe(true);
|
|
});
|
|
|
|
it('should detect socket hang up errors', () => {
|
|
expect(isTransientConnectionError(new Error('socket hang up'))).toBe(true);
|
|
});
|
|
|
|
it('should return false for undefined error', () => {
|
|
expect(isTransientConnectionError(undefined)).toBe(false);
|
|
});
|
|
|
|
it('should return false for non-connection errors', () => {
|
|
expect(isTransientConnectionError(new Error('Invalid API key'))).toBe(false);
|
|
});
|
|
|
|
it('should return false for rate limit errors', () => {
|
|
expect(isTransientConnectionError(new Error('429 Too Many Requests'))).toBe(false);
|
|
});
|
|
|
|
it('should not match EPROTO with permanent certificate/config errors', () => {
|
|
// wrong version number (HTTPS→HTTP mismatch)
|
|
expect(
|
|
isTransientConnectionError(
|
|
new Error('write EPROTO 00000000:error:0A000102:SSL routines::wrong version number'),
|
|
),
|
|
).toBe(false);
|
|
// self-signed certificate
|
|
expect(
|
|
isTransientConnectionError(
|
|
new Error('write EPROTO: self signed certificate in certificate chain'),
|
|
),
|
|
).toBe(false);
|
|
// unable to verify
|
|
expect(
|
|
isTransientConnectionError(new Error('write EPROTO: unable to verify the first certificate')),
|
|
).toBe(false);
|
|
// unknown CA
|
|
expect(isTransientConnectionError(new Error('write EPROTO: unknown ca'))).toBe(false);
|
|
// expired certificate
|
|
expect(isTransientConnectionError(new Error('write EPROTO: certificate has expired'))).toBe(
|
|
false,
|
|
);
|
|
// cert keyword
|
|
expect(isTransientConnectionError(new Error('write EPROTO: cert_untrusted'))).toBe(false);
|
|
});
|
|
|
|
it('should still match plain EPROTO without permanent phrases', () => {
|
|
expect(isTransientConnectionError(new Error('write EPROTO 00000000:error:0A000126'))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('should detect ECONNRESET via error.code', () => {
|
|
const error = new Error('read failed');
|
|
(error as any).code = 'ECONNRESET';
|
|
expect(isTransientConnectionError(error)).toBe(true);
|
|
});
|
|
|
|
it('should detect EPIPE via error.code', () => {
|
|
const error = new Error('write failed');
|
|
(error as any).code = 'EPIPE';
|
|
expect(isTransientConnectionError(error)).toBe(true);
|
|
});
|
|
|
|
it('should not match ECONNREFUSED via error.code', () => {
|
|
const error = new Error('connect failed');
|
|
(error as any).code = 'ECONNREFUSED';
|
|
expect(isTransientConnectionError(error)).toBe(false);
|
|
});
|
|
|
|
it('should not match permanent SSL/TLS errors', () => {
|
|
expect(isTransientConnectionError(new Error('self signed certificate'))).toBe(false);
|
|
expect(isTransientConnectionError(new Error('unable to verify the first certificate'))).toBe(
|
|
false,
|
|
);
|
|
expect(isTransientConnectionError(new Error('certificate has expired'))).toBe(false);
|
|
expect(isTransientConnectionError(new Error('UNABLE_TO_GET_ISSUER_CERT'))).toBe(false);
|
|
expect(isTransientConnectionError(new Error('ssl routines:ssl3_read_bytes'))).toBe(false);
|
|
expect(isTransientConnectionError(new Error('tls alert unknown ca'))).toBe(false);
|
|
expect(isTransientConnectionError(new Error('wrong version number'))).toBe(false);
|
|
});
|
|
});
|