- A read with no answer after 2.5 s is asked a second time alongside; the first answer wins. A stalled connection no longer costs the whole timeout. - When a server does not answer, its last good answer stands in for up to two minutes past its cache time. Co-Authored-By: Claude Fable 5.2 <[email protected]>
69 lines
2.8 KiB
TypeScript
69 lines
2.8 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { Upstream } from './upstream.ts'
|
|
import type { HubConfig } from './config.ts'
|
|
|
|
const server = { id: 'a', url: 'http://a.test', token: 't' }
|
|
const config = { cacheSeconds: 15, timeoutMs: 20 } as HubConfig
|
|
const ok = () => new Response(JSON.stringify({ status: 'ok', response: { n: 1 } }), { headers: { 'content-type': 'application/json' } })
|
|
|
|
describe('Upstream', () => {
|
|
it('shares one request among reads asked at the same time', async () => {
|
|
let calls = 0
|
|
const up = new Upstream(config, (async () => {
|
|
calls++
|
|
return ok()
|
|
}) as typeof fetch)
|
|
await Promise.all([up.call(server, 'x'), up.call(server, 'x'), up.call(server, 'x')])
|
|
expect(calls).toBe(1)
|
|
})
|
|
|
|
it('names the HTTP status when a proxy answers with a page', async () => {
|
|
const up = new Upstream(config, (async () =>
|
|
new Response('Bad Gateway', { status: 502, statusText: 'Bad Gateway', headers: { 'content-type': 'text/plain' } })) as typeof fetch)
|
|
await expect(up.call(server, 'x')).rejects.toThrow('HTTP 502 Bad Gateway')
|
|
})
|
|
|
|
it('after a timeout, fails reads of that server at once for a while', async () => {
|
|
let now = 0
|
|
let calls = 0
|
|
const hang = ((_url: string, init: RequestInit) => {
|
|
calls++
|
|
return new Promise((_resolve, reject) => init.signal?.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))))
|
|
}) as unknown as typeof fetch
|
|
const up = new Upstream(config, hang, () => now)
|
|
await expect(up.call(server, 'x')).rejects.toThrow('timed out after 20 ms')
|
|
await expect(up.call(server, 'y')).rejects.toThrow('timed out')
|
|
// Two: the read and its second try.
|
|
expect(calls).toBe(2)
|
|
now = 11_000
|
|
await expect(up.call(server, 'y')).rejects.toThrow('timed out')
|
|
expect(calls).toBe(4)
|
|
})
|
|
|
|
it('asks a slow read again and takes the answer that comes first', async () => {
|
|
let calls = 0
|
|
const up = new Upstream(config, ((_url: string, init: RequestInit) => {
|
|
calls++
|
|
if (calls > 1) return Promise.resolve(ok())
|
|
return new Promise((_resolve, reject) => init.signal?.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))))
|
|
}) as unknown as typeof fetch)
|
|
await expect(up.call(server, 'x')).resolves.toEqual({ n: 1 })
|
|
expect(calls).toBe(2)
|
|
})
|
|
|
|
it('serves the last good answer for two minutes when the server stops answering', async () => {
|
|
let now = 0
|
|
let fail = false
|
|
const up = new Upstream(config, (async () => {
|
|
if (fail) throw new Error('ECONNRESET')
|
|
return ok()
|
|
}) as typeof fetch, () => now)
|
|
await up.call(server, 'x')
|
|
fail = true
|
|
now = 60_000
|
|
await expect(up.call(server, 'x')).resolves.toEqual({ n: 1 })
|
|
now = 140_000
|
|
await expect(up.call(server, 'x')).rejects.toThrow('ECONNRESET')
|
|
})
|
|
})
|