File size: 6,617 Bytes
dcedb25 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { initDb, getDb } from '../../db/index.js';
import { runFallbackLoop, newFallbackState, type FallbackState } from '../../lib/fallback-loop.js';
import { isClientAbortError, newClientAbortError, isRetryableError } from '../../lib/error-classify.js';
import { acquireLease, releaseLease, resetLeases, inFlightForKey } from '../../services/ratelimit.js';
import type { RouteResult } from '../../services/router.js';
/**
* Client-abort semantics in the shared fallback loop (P1 AbortSignal work):
* when the gateway's OWN client hangs up, the composed fetch signal rejects
* the in-flight attempt with the marked error from newClientAbortError. That
* throw must stop the ladder immediately, release the in-flight lease via the
* existing finally, and leave NO provider-failure trace — no cooldown, no
* skip-key entry, no logFailure row, no exhaustion render. What it DOES leave
* (#752) is a 'canceled' requests row, so the dashboard shows the request
* happened; stats/scoring queries exclude that status everywhere.
*/
let keySeq = 900;
function leasedRoute(): { route: RouteResult; keyId: number } {
const keyId = ++keySeq;
const id = acquireLease('fake', 'fake-model', keyId, 100);
return {
keyId,
route: {
provider: {} as any,
modelId: 'fake-model',
modelDbId: 900000 + keyId,
apiKey: 'test-key',
keyId,
platform: 'fake',
displayName: 'Fake Model',
rpdLimit: null,
tpdLimit: null,
release: () => releaseLease(id),
},
};
}
function hooks(state: FallbackState, overrides: Record<string, unknown>) {
return {
state,
timeBudgetMs: 0,
logFailure: vi.fn(),
onFatal: vi.fn(),
onRoutingExhausted: vi.fn(),
onExhausted: vi.fn(),
...overrides,
} as any;
}
describe('fallback loop on client abort', () => {
beforeEach(() => {
initDb(':memory:');
resetLeases();
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
resetLeases();
vi.restoreAllMocks();
});
it('classifies the marked error as client-abort, and not as retryable', () => {
const err = newClientAbortError();
expect(isClientAbortError(err)).toBe(true);
// Must never enter the retryable ladder as a provider failure.
expect(isRetryableError(err)).toBe(false);
// Ordinary aborts/timeouts keep their provider-timeout classification.
expect(isClientAbortError(new DOMException('The operation was aborted (groq, chat, 15s)', 'AbortError'))).toBe(false);
});
it('stops the ladder immediately — no further attempts, nothing rendered', async () => {
const state = newFallbackState();
const dispatch = vi.fn(async () => { throw newClientAbortError(); });
const h = hooks(state, {
maxRetries: 5,
route: () => leasedRoute().route,
dispatch,
});
await runFallbackLoop(h);
expect(dispatch).toHaveBeenCalledTimes(1);
// The socket is gone: no exhaustion body, no fatal render, no error row.
expect(h.logFailure).not.toHaveBeenCalled();
expect(h.onFatal).not.toHaveBeenCalled();
expect(h.onExhausted).not.toHaveBeenCalled();
expect(h.onRoutingExhausted).not.toHaveBeenCalled();
});
it('releases the in-flight lease (mid-stream abort surfaces as a dispatch throw)', async () => {
const state = newFallbackState();
const { route, keyId } = leasedRoute();
// Mirror a stream pump: one chunk is consumed, then the client hangs up
// and the next upstream read rejects with the marked abort.
async function* abortedStream() {
yield { choices: [{ delta: { content: 'hel' } }] };
throw newClientAbortError();
}
await runFallbackLoop(hooks(state, {
route: () => route,
dispatch: async () => {
for await (const _chunk of abortedStream()) { /* pump until the abort */ }
return 'done' as const;
},
}));
expect(inFlightForKey('fake', keyId)).toBe(0);
});
it('records no cooldown, no skip entry, and no failure bookkeeping', async () => {
const state = newFallbackState();
const { route, keyId } = leasedRoute();
await runFallbackLoop(hooks(state, {
route: () => route,
dispatch: async () => { throw newClientAbortError(); },
}));
// No cooldown row: a vanished client is not a provider-health signal.
const cooldown = getDb().prepare('SELECT 1 FROM rate_limit_cooldowns WHERE platform = ? AND key_id = ?').get('fake', keyId);
expect(cooldown).toBeUndefined();
// No per-request skip state either — nothing was "tried and failed".
expect(state.skipKeys.size).toBe(0);
expect(state.skipModels.size).toBe(0);
});
it("writes a 'canceled' requests row so the abort is visible in the dashboard (#752)", async () => {
const state = newFallbackState();
const { route } = leasedRoute();
await runFallbackLoop(hooks(state, {
route: () => route,
dispatch: async () => { throw newClientAbortError(); },
}));
const row = getDb().prepare(
'SELECT id, platform, model_id, key_id, status, error FROM requests ORDER BY id DESC LIMIT 1',
).get() as any;
expect(row).toMatchObject({ platform: 'fake', model_id: 'fake-model', status: 'canceled' });
expect(row.error).toContain('client disconnected');
expect(row.error).toContain('upstream request canceled');
// The attempt trace persists too, keyed to the canceled row — pure aborts
// used to leave no parent row and therefore no ladder either.
const attempts = getDb().prepare('SELECT outcome FROM request_attempts WHERE request_id = ?').all(row.id) as any[];
expect(attempts.map(a => a.outcome)).toEqual(['client_abort']);
});
it('clientGone still stops the loop before the next attempt after an ordinary failure', async () => {
// The pre-existing boundary: a NON-abort provider failure that lands after
// the disconnect is still recorded normally, but the loop must not start
// another attempt for a client that is gone.
const state = newFallbackState();
const dispatch = vi.fn(async () => {
throw Object.assign(new Error('fake API error 429: rate limit'), { status: 429 });
});
let gone = false;
await runFallbackLoop(hooks(state, {
maxRetries: 5,
clientGone: () => gone,
route: () => leasedRoute().route,
dispatch: async () => {
gone = true; // client vanishes during the first attempt
return dispatch();
},
}));
expect(dispatch).toHaveBeenCalledTimes(1);
});
});
|