|
1 | 1 | /** @vitest-environment node */ |
2 | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
3 | 3 | import { getHostedModels, getModelCapabilities, getProviderIcon } from '@/providers/models' |
| 4 | +import { PROVIDER_MAX_RETRIES } from '@/providers/transport' |
4 | 5 | import type { ProviderRequest } from '@/providers/types' |
5 | 6 | import { typesafeProvider } from '@/providers/typesafe' |
6 | 7 | import { buildJevBody, parseJevResponse } from '@/providers/typesafe/schema' |
@@ -52,7 +53,11 @@ describe('TypeSafe provider', () => { |
52 | 53 | fetchMock.mockReset().mockResolvedValue(Response.json(RESULT)) |
53 | 54 | vi.stubGlobal('fetch', fetchMock) |
54 | 55 | }) |
55 | | - afterEach(() => vi.unstubAllGlobals()) |
| 56 | + afterEach(() => { |
| 57 | + vi.unstubAllGlobals() |
| 58 | + vi.restoreAllMocks() |
| 59 | + vi.useRealTimers() |
| 60 | + }) |
56 | 61 |
|
57 | 62 | it.each(['jev-1.13.0', 'jev-latest', 'jev-preview'])( |
58 | 63 | 'routes %s through native BYOK evaluation', |
@@ -131,6 +136,79 @@ describe('TypeSafe provider', () => { |
131 | 136 | await expect(typesafeProvider.executeRequest(REQUEST)).rejects.toThrow( |
132 | 137 | 'TypeSafe evaluation failed (HTTP 401)' |
133 | 138 | ) |
| 139 | + expect(fetchMock).toHaveBeenCalledTimes(1) |
| 140 | + }) |
| 141 | + |
| 142 | + it.each([408, 429, 500, 503])('retries HTTP %s and honors Retry-After', async (status) => { |
| 143 | + vi.useFakeTimers() |
| 144 | + fetchMock.mockResolvedValueOnce(new Response(null, { status, headers: { 'retry-after': '2' } })) |
| 145 | + const result = typesafeProvider.executeRequest(REQUEST) |
| 146 | + await vi.advanceTimersByTimeAsync(1999) |
| 147 | + expect(fetchMock).toHaveBeenCalledTimes(1) |
| 148 | + await vi.advanceTimersByTimeAsync(1) |
| 149 | + expect(await result).toMatchObject({ answers: RESULT.answers }) |
| 150 | + expect(fetchMock).toHaveBeenCalledTimes(2) |
| 151 | + }) |
| 152 | + |
| 153 | + it.each([400, 403, 422])('does not retry HTTP %s', async (status) => { |
| 154 | + fetchMock.mockResolvedValueOnce(new Response(null, { status })) |
| 155 | + await expect(typesafeProvider.executeRequest(REQUEST)).rejects.toThrow(`HTTP ${status}`) |
| 156 | + expect(fetchMock).toHaveBeenCalledTimes(1) |
| 157 | + }) |
| 158 | + |
| 159 | + it('retries connection failures within the shared provider retry budget', async () => { |
| 160 | + vi.useFakeTimers() |
| 161 | + fetchMock.mockRejectedValue(new TypeError('fetch failed')) |
| 162 | + const result = expect(typesafeProvider.executeRequest(REQUEST)).rejects.toThrow('fetch failed') |
| 163 | + await vi.runAllTimersAsync() |
| 164 | + await result |
| 165 | + expect(fetchMock).toHaveBeenCalledTimes(PROVIDER_MAX_RETRIES + 1) |
| 166 | + }) |
| 167 | + |
| 168 | + it('stops retrying repeated server failures', async () => { |
| 169 | + vi.useFakeTimers() |
| 170 | + fetchMock.mockImplementation(async () => new Response(null, { status: 503 })) |
| 171 | + const result = expect(typesafeProvider.executeRequest(REQUEST)).rejects.toThrow('HTTP 503') |
| 172 | + await vi.runAllTimersAsync() |
| 173 | + await result |
| 174 | + expect(fetchMock).toHaveBeenCalledTimes(PROVIDER_MAX_RETRIES + 1) |
| 175 | + }) |
| 176 | + |
| 177 | + it('gives a timed-out attempt a fresh deadline', async () => { |
| 178 | + vi.useFakeTimers() |
| 179 | + const deadline = new AbortController() |
| 180 | + vi.spyOn(AbortSignal, 'timeout').mockReturnValueOnce(deadline.signal) |
| 181 | + fetchMock.mockImplementationOnce(async () => { |
| 182 | + deadline.abort(new DOMException('Timed out', 'TimeoutError')) |
| 183 | + throw deadline.signal.reason |
| 184 | + }) |
| 185 | + const result = typesafeProvider.executeRequest(REQUEST) |
| 186 | + await vi.runAllTimersAsync() |
| 187 | + expect(await result).toMatchObject({ answers: RESULT.answers }) |
| 188 | + expect(fetchMock).toHaveBeenCalledTimes(2) |
| 189 | + expect(fetchMock.mock.calls[1][1]?.signal?.aborted).toBe(false) |
| 190 | + }) |
| 191 | + |
| 192 | + it('cancels immediately during Retry-After without sending another attempt', async () => { |
| 193 | + vi.useFakeTimers() |
| 194 | + const controller = new AbortController() |
| 195 | + fetchMock.mockResolvedValueOnce( |
| 196 | + new Response(null, { status: 429, headers: { 'retry-after': '30' } }) |
| 197 | + ) |
| 198 | + const result = expect( |
| 199 | + typesafeProvider.executeRequest({ ...REQUEST, abortSignal: controller.signal }) |
| 200 | + ).rejects.toThrow('Cancelled') |
| 201 | + await vi.advanceTimersByTimeAsync(1) |
| 202 | + controller.abort(new Error('Cancelled')) |
| 203 | + await result |
| 204 | + expect(fetchMock).toHaveBeenCalledTimes(1) |
| 205 | + expect(vi.getTimerCount()).toBe(0) |
| 206 | + }) |
| 207 | + |
| 208 | + it('does not retry a malformed successful response', async () => { |
| 209 | + fetchMock.mockResolvedValueOnce(new Response('{')) |
| 210 | + await expect(typesafeProvider.executeRequest(REQUEST)).rejects.toThrow() |
| 211 | + expect(fetchMock).toHaveBeenCalledTimes(1) |
134 | 212 | }) |
135 | 213 |
|
136 | 214 | it('honors cancellation before network access', async () => { |
@@ -217,4 +295,14 @@ describe('Jev native schema', () => { |
217 | 295 | ])('rejects invalid provider responses', (value) => { |
218 | 296 | expect(() => parseJevResponse(value, QUESTIONS)).toThrow('invalid Jev evaluation response') |
219 | 297 | }) |
| 298 | + |
| 299 | + it.each(['unknown', 'toString'])('rejects an unrequested Choice option %s', (choice) => { |
| 300 | + const answers = { |
| 301 | + ...RESULT.answers, |
| 302 | + department: { type: 'choice', choice, probabilities: { [choice]: 1 }, confidence: 1 }, |
| 303 | + } |
| 304 | + expect(() => parseJevResponse({ ...RESULT, answers }, QUESTIONS)).toThrow( |
| 305 | + 'outside the requested options' |
| 306 | + ) |
| 307 | + }) |
220 | 308 | }) |
0 commit comments