Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion src/lib/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="node" />
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,
Expand All @@ -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("<html>") }) as unknown as Response
await assert.rejects(api("/nodes"), { status: 200, message: "收到的不是状态数据,稍后再试" })
console.log("timeouts are retried and read as network failures")
}
51 changes: 41 additions & 10 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,18 +92,46 @@ async function failure(res: Response): Promise<ApiError> {
)
}

/**
* 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<T>(path: string): Promise<T> {
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. */
Expand Down Expand Up @@ -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)
})
Expand Down
Loading