Hub: ask a slow read again, serve the last good answer when a server stalls
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
18d98449b2
commit
f800d5f07a
2 files changed
+72
-3
No files matched your search
+28
-1
@@ -33,9 +33,36 @@ describe('Upstream', () => {
|
|||||||
const up = new Upstream(config, hang, () => now)
|
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, 'x')).rejects.toThrow('timed out after 20 ms')
|
||||||
await expect(up.call(server, 'y')).rejects.toThrow('timed out')
|
await expect(up.call(server, 'y')).rejects.toThrow('timed out')
|
||||||
expect(calls).toBe(1)
|
// Two: the read and its second try.
|
||||||
|
expect(calls).toBe(2)
|
||||||
now = 11_000
|
now = 11_000
|
||||||
await expect(up.call(server, 'y')).rejects.toThrow('timed out')
|
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)
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
+44
-2
@@ -43,6 +43,12 @@ const WRITE_TIMEOUT_MS = 60_000
|
|||||||
/* How long reads of a server that timed out fail at once (the console polls every few seconds). */
|
/* How long reads of a server that timed out fail at once (the console polls every few seconds). */
|
||||||
const DOWN_MS = 10_000
|
const DOWN_MS = 10_000
|
||||||
|
|
||||||
|
/* How long a read may wait before it is asked again alongside. */
|
||||||
|
const HEDGE_MS = 2500
|
||||||
|
|
||||||
|
/* How long past its time a cached answer still stands in for a server that does not answer. */
|
||||||
|
const STALE_MS = 120_000
|
||||||
|
|
||||||
export class Upstream {
|
export class Upstream {
|
||||||
#config: HubConfig
|
#config: HubConfig
|
||||||
#fetch: typeof fetch
|
#fetch: typeof fetch
|
||||||
@@ -64,13 +70,18 @@ export class Upstream {
|
|||||||
const hit = this.#cache.get(key)
|
const hit = this.#cache.get(key)
|
||||||
if (hit !== undefined && hit.until > this.#now()) return hit.response
|
if (hit !== undefined && hit.until > this.#now()) return hit.response
|
||||||
// A server that just timed out fails again at once, rather than holding every read for the whole timeout.
|
// A server that just timed out fails again at once, rather than holding every read for the whole timeout.
|
||||||
|
// The last good answer, kept a little past its time: it stands in when the server does not answer.
|
||||||
|
const stale = hit !== undefined && hit.until + STALE_MS > this.#now() ? hit : undefined
|
||||||
const down = this.#down.get(server.id)
|
const down = this.#down.get(server.id)
|
||||||
if (down !== undefined && down.until > this.#now()) throw down.error
|
if (down !== undefined && down.until > this.#now()) {
|
||||||
|
if (stale !== undefined) return stale.response
|
||||||
|
throw down.error
|
||||||
|
}
|
||||||
// The same read already on its way (the sidebar, the page and a panel ask together) is shared.
|
// The same read already on its way (the sidebar, the page and a panel ask together) is shared.
|
||||||
const pending = this.#pending.get(key)
|
const pending = this.#pending.get(key)
|
||||||
if (pending !== undefined) return pending
|
if (pending !== undefined) return pending
|
||||||
|
|
||||||
const call = this.#exec(server, path, new URLSearchParams(sorted), token, { method: 'GET' }, this.#config.timeoutMs).then(
|
const call = this.#read(server, path, new URLSearchParams(sorted), token).then(
|
||||||
(response) => {
|
(response) => {
|
||||||
this.#down.delete(server.id)
|
this.#down.delete(server.id)
|
||||||
if (this.#config.cacheSeconds > 0) {
|
if (this.#config.cacheSeconds > 0) {
|
||||||
@@ -81,6 +92,7 @@ export class Upstream {
|
|||||||
},
|
},
|
||||||
(e: unknown) => {
|
(e: unknown) => {
|
||||||
if (e instanceof UpstreamError && e.message.startsWith('timed out')) this.#down.set(server.id, { until: this.#now() + DOWN_MS, error: e })
|
if (e instanceof UpstreamError && e.message.startsWith('timed out')) this.#down.set(server.id, { until: this.#now() + DOWN_MS, error: e })
|
||||||
|
if (stale !== undefined && e instanceof UpstreamError && e.kind === 'unreachable') return stale.response
|
||||||
throw e
|
throw e
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -92,6 +104,36 @@ export class Upstream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
One read, asked a second time when the first is slow to answer: a request can stall on its own
|
||||||
|
(a lost packet, a TLS handshake that hangs) while the server is fine, and a fresh one then answers
|
||||||
|
at once. Whichever answers first wins; the first one's error is the error when both fail.
|
||||||
|
shortcut: the losing request is left to finish or time out by itself, abort it if reads get heavy.
|
||||||
|
*/
|
||||||
|
#read(server: HubServer, path: string, query: URLSearchParams, token: string): Promise<unknown> {
|
||||||
|
const total = this.#config.timeoutMs
|
||||||
|
const wait = Math.min(HEDGE_MS, total / 3)
|
||||||
|
const first = this.#exec(server, path, new URLSearchParams(query), token, { method: 'GET' }, total)
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let second: Promise<unknown> | undefined
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
second = this.#exec(server, path, new URLSearchParams(query), token, { method: 'GET' }, total - wait)
|
||||||
|
second.then(resolve, () => {})
|
||||||
|
}, wait)
|
||||||
|
first.then(
|
||||||
|
(r) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
resolve(r)
|
||||||
|
},
|
||||||
|
(e: unknown) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (second === undefined) reject(e)
|
||||||
|
else second.then(resolve, () => reject(e))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
A write: POST with the server's ADMIN token and never the read one, never cached. Whatever
|
A write: POST with the server's ADMIN token and never the read one, never cached. Whatever
|
||||||
the outcome, what the hub remembers of this server is dropped afterwards, so the next read
|
the outcome, what the hub remembers of this server is dropped afterwards, so the next read
|
||||||
|
|||||||
Reference in new issue
Block a user