From 178ddc7a4379b9d625e1094c55ef03045986f097 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:24:08 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E8=AF=B7=E6=B1=82=2020=20=E7=A7=92?= =?UTF-8?q?=E6=97=A0=E5=93=8D=E5=BA=94=E6=8C=89=E7=BD=91=E7=BB=9C=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/api.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index f9a8800..5647d96 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -92,10 +92,20 @@ async function failure(res: Response): Promise { ) } +/** + * How long a request 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 abandons the dead + * connection after 10 s itself. Healthy, the slowest request here, a node's + * history, answers within 3 s on 4G. + */ +const TIMEOUT = 20_000 + export async function api(path: string): Promise { let res: Response try { - res = await fetch(`/api${path}`) + res = await fetch(`/api${path}`, { signal: AbortSignal.timeout(TIMEOUT) }) } catch { throw new ApiError(0, "网络连接失败,稍后再试") } From 9f85001b243b371575655941011ccb4b7f52cdc2 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:49:44 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E8=B6=85=E6=97=B6=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E6=8D=A2=E4=B8=8B=E4=B8=80=E6=9D=A1=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=EF=BC=9B=E8=AF=BB=E6=AD=A3=E6=96=87=E6=97=B6=E4=B8=AD?= =?UTF-8?q?=E6=96=AD=E5=90=8C=E6=A0=B7=E6=8C=89=E7=BD=91=E7=BB=9C=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/api.test.ts | 25 ++++++++++++++++++++- src/lib/api.ts | 53 +++++++++++++++++++++++++++++++-------------- 2 files changed, 61 insertions(+), 17 deletions(-) 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 5647d96..587b6a9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -93,27 +93,45 @@ async function failure(res: Response): Promise { } /** - * How long a request 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 abandons the dead - * connection after 10 s itself. Healthy, the slowest request here, a node's - * history, answers within 3 s on 4G. + * 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}`, { signal: AbortSignal.timeout(TIMEOUT) }) - } 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. */ @@ -164,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) })