diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index d919029..4d88aad 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -1,6 +1,6 @@ /// import assert from "node:assert/strict" -import { groupsOf, safeNodes, type Node } from "./api.ts" +import { api, groupsOf, safeNodes, type Node } from "./api.ts" const node = { id: 1, metrics: { uptime: 100, cpu: 1, load: [0.1, 0.2, 0.3], mem_total: 1024, mem_used: 512, swap_total: 0, swap_used: 0, disk_total: 2048, disk_used: 1024, @@ -18,3 +18,26 @@ console.log("invalid live reports are isolated") // Tabs follow the node order; ungrouped nodes and a hub without the field add none. assert.deepEqual(groupsOf([{ group: "东京" }, { group: "" }, {}, { group: "香港" }, { group: "东京" }]), ["东京", "香港"]) console.log("groups follow the node order") + +// A timeout is retried, since the next attempt may take a live connection; a +// read cut off by it reads as a network failure, never as the browser's message. +{ + let calls = 0 + const timeout = () => new DOMException("signal timed out", "TimeoutError") + globalThis.fetch = async () => { + if (++calls < 3) throw timeout() + return { ok: true, status: 200, json: async () => ({ nodes: [] }) } as unknown as Response + } + assert.deepEqual(await api("/nodes"), { nodes: [] }) + assert.equal(calls, 3) + globalThis.fetch = async () => { + throw timeout() + } + await assert.rejects(api("/nodes"), { status: 0, message: "网络连接失败,稍后再试" }) + globalThis.fetch = async () => + ({ ok: false, status: 503, headers: new Headers({ "content-type": "text/plain" }), text: async () => { throw new DOMException("aborted", "AbortError") } }) as unknown as Response + await assert.rejects(api("/nodes"), { status: 0 }) + globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => JSON.parse("") }) as unknown as Response + await assert.rejects(api("/nodes"), { status: 200, message: "收到的不是状态数据,稍后再试" }) + console.log("timeouts are retried and read as network failures") +} diff --git a/src/lib/api.ts b/src/lib/api.ts index f9a8800..587b6a9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -92,18 +92,46 @@ async function failure(res: Response): Promise { ) } +/** + * How long one attempt may go unanswered. A connection that died without + * closing, as when a NAT on a phone's path forgets it, holds a request sent over + * it until TCP gives up: still pending after 300 s in Chrome over HTTP/1.1, + * which nginx serves unless configured for HTTP/2. Over HTTP/2 Chrome replaces + * the dead connection after 10 s itself, but only for a request sent once the + * connection has been idle 10 s; one sent sooner hangs as over HTTP/1.1. + * Healthy, the slowest request here, a node's history, answers within 3 s on 4G. + */ +const TIMEOUT = 20_000 + +/** + * Further attempts after a timeout. The aborted connection is closed, but the + * next attempt takes the next idle one, which may be dead as well: Chrome keeps + * up to six per host, and with four dead the fifth attempt was the first to + * succeed. Every request here is a GET, so repeating one is safe. + */ +const RETRIES = 6 + export async function api(path: string): Promise { - let res: Response - try { - res = await fetch(`/api${path}`) - } catch { - throw new ApiError(0, "网络连接失败,稍后再试") + for (let attempt = 0; ; attempt++) { + try { + const res = await fetch(`/api${path}`, { signal: AbortSignal.timeout(TIMEOUT) }) + if (!res.ok) throw await failure(res) + // A 200 carrying HTML is a proxy's page, not the hub's JSON. + return await res.json().catch((e) => { + throw e instanceof SyntaxError ? new ApiError(res.status, "收到的不是状态数据,稍后再试") : e + }) + } catch (e) { + if (e instanceof ApiError) throw e + // Anything else failed on the network, the body's read included, where + // the timeout also applies. Chrome names a timeout before the response + // TimeoutError and one during the body's read AbortError; nothing else + // here aborts a request. + const timedOut = e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError") + if (!timedOut || attempt === RETRIES) { + throw new ApiError(0, "网络连接失败,稍后再试") + } + } } - if (!res.ok) throw await failure(res) - // A 200 carrying HTML is a proxy's page, not the hub's JSON. - return res.json().catch(() => { - throw new ApiError(res.status, "收到的不是状态数据,稍后再试") - }) } /** A malformed report must not remove every other node from the page. */ @@ -154,6 +182,9 @@ export function useNodes() { }) .catch((e: Error) => { if (started !== epoch) return + // A request lost on a dead pooled connection says nothing of an open + // stream, whose own watchdog reports it going quiet. + if (e instanceof ApiError && e.status === 0 && socket?.readyState === WebSocket.OPEN) return setError(e.message) if (e instanceof ApiError && e.status === 401) setClosed(true) })