fix(frontend): every request carries a deadline, and expiry arrives as an error callers already handle (#3412)
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / integration (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m10s
CI & Build / Build & push image (push) Successful in 34s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / integration (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m10s
CI & Build / Build & push image (push) Successful in 34s
Rule 156, across the whole client. `apiGet`, `apiPost`, `apiPut`, `apiPatch` and `apiDelete` each called bare `fetch`, whose default is to wait as long as the browser will — not a long timeout but the absence of one. The only AbortController in the frontend belonged to the SSE stream and was for cancellation. So every request in the app could hang forever, and there is no state a surface can render for "pending forever" that is not a lie: the spinner that never resolves looks exactly like work still in progress. Found while building the version readout (#3329), which had to tell "the fetch failed" apart from "still loading" and could not. ONE REQUEST PATH. The five verbs were near-identical bodies; they now delegate to a single `request()` that owns the deadline, so a sixth verb cannot be added without one. 30s by default — long enough to clear a cold embedding call and a list view under pool contention (#2384), so tripping it means something is wrong rather than merely busy. Overridable per call via `timeoutMs`. EXPIRY IS AN ApiError, which is the half of rule 156 that is easy to skip. A raw `DOMException: TimeoutError` reaches `apiErrorMessage(e, fallback)` as an object with no `body`, so all ~330 existing catch sites would have printed their generic fallback and the timeout would have been invisible in exactly the situation it exists to expose. Rethrown as `ApiError` with a 408 — a status no Scribe route returns, so it unambiguously means the client gave up — every one of those call sites now reports it correctly, untouched. Only TimeoutError is converted. A deliberate cancellation aborts with AbortError and passes through: a caller that cancelled its own request does not want that surfaced as a server failure. Pinned by a test, because collapsing the two is the obvious "simplification". STREAMS RELOCATE THE DEADLINE RATHER THAN ESCAPING IT. A wall-clock timeout would kill a long-lived SSE connection mid-flight, but two different waits are involved and only one of them is the stream: the CONNECT can fail to answer and now carries a 15s deadline, cleared the moment headers arrive; the BODY stays unbounded on purpose, since its failure mode is going quiet, which a timeout cannot distinguish from being idle — that is what reconnection and Last-Event-ID are for. Reading the connect as exempt because "the stream is long-lived" leaves an unreachable server looking like a quiet one. BULK TRANSFERS get their own value, not the default. Backup, notes export and admin restore walk the whole store and 30s would cut them off mid-work; they carry 10 minutes. Bounded, not unbounded — rule 156 asks for a deadline, not a short one, and no ceiling at all is what leaves a restore that died server-side spinning forever. Four source-inspection guards in the unit lane (no frontend test runner): no bare fetch anywhere; the default is actually applied — pinning the specific regression, since #3329's opt-in shape would pass every other check while leaving 330 callers unbounded; expiry converts to ApiError; and cancellation does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
"""Every request the web UI makes has a deadline (rule 156).
|
||||
|
||||
A source-inspection guard in the unit lane — there is no frontend test runner,
|
||||
and this is a property of the source rather than of a rendered result, so
|
||||
reading the source is the honest way to check it.
|
||||
|
||||
WHY. `fetch`'s default is to wait as long as the browser will. That is not a
|
||||
long timeout, it is the absence of one, and there is no state a surface can
|
||||
render for "pending forever" that is not a lie — the spinner that never
|
||||
resolves is indistinguishable from work still in progress. Rule 156 names
|
||||
`fetch` specifically:
|
||||
|
||||
When a library's default is "wait indefinitely" — `fetch`, most HTTP
|
||||
clients, a bare `await` on a stream — supplying the deadline is part of
|
||||
using it, not a hardening pass for later.
|
||||
|
||||
Before this guard, no request in the app carried one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend" / "src"
|
||||
CLIENT = FRONTEND / "api" / "client.ts"
|
||||
|
||||
|
||||
def _call_text(src: str, start: int) -> str:
|
||||
"""The source of one `fetch(...)` call, from its open paren to its close.
|
||||
|
||||
Naive paren balancing. Adequate because every call site here passes an
|
||||
object literal, and a construct complex enough to defeat it is one worth
|
||||
looking at by hand anyway.
|
||||
"""
|
||||
depth = 0
|
||||
for i in range(start, len(src)):
|
||||
if src[i] == "(":
|
||||
depth += 1
|
||||
elif src[i] == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[start:i + 1]
|
||||
return src[start:]
|
||||
|
||||
|
||||
def _fetch_calls() -> list[tuple[Path, str]]:
|
||||
calls: list[tuple[Path, str]] = []
|
||||
for path in list(FRONTEND.rglob("*.ts")) + list(FRONTEND.rglob("*.vue")):
|
||||
src = path.read_text()
|
||||
for m in re.finditer(r"\bfetch\(", src):
|
||||
calls.append((path, _call_text(src, m.end() - 1)))
|
||||
return calls
|
||||
|
||||
|
||||
def test_every_fetch_passes_a_signal():
|
||||
"""No bare `fetch` anywhere in the frontend.
|
||||
|
||||
Stated on the SIGNAL rather than on a timeout value, because the two
|
||||
legitimate shapes here produce different values and only share this: an
|
||||
ordinary call takes the client's default, a stream bounds its CONNECT and
|
||||
then deliberately runs unbounded, and a bulk transfer passes minutes. What
|
||||
they must all do is pass something.
|
||||
"""
|
||||
naked = [
|
||||
f"{path.relative_to(FRONTEND)}: {call[:70]}"
|
||||
for path, call in _fetch_calls()
|
||||
if "signal:" not in call
|
||||
]
|
||||
assert not naked, (
|
||||
"these fetch calls carry no AbortSignal, so they wait forever "
|
||||
"(rule 156):\n " + "\n ".join(naked)
|
||||
)
|
||||
|
||||
|
||||
def test_the_client_applies_its_deadline_by_default():
|
||||
"""The specific regression that would silently undo this.
|
||||
|
||||
An earlier pass (#3329) made `timeoutMs` OPT-IN and used it at exactly one
|
||||
call site, which left ~330 others waiting forever while the mechanism
|
||||
looked present. Reverting to that shape would not fail the guard above —
|
||||
every call would still reach `fetch` through `request()` — so the default
|
||||
is pinned here separately.
|
||||
|
||||
`??` is the operative character: `opts?.timeoutMs || DEFAULT` would treat
|
||||
an explicit 0 as "use the default", and `opts?.timeoutMs` alone would
|
||||
reinstate the opt-in bug.
|
||||
"""
|
||||
src = CLIENT.read_text()
|
||||
assert "opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS" in src, (
|
||||
"request() must fall back to DEFAULT_TIMEOUT_MS — without it the "
|
||||
"deadline is opt-in again and almost nothing opts in"
|
||||
)
|
||||
|
||||
|
||||
def test_a_timeout_arrives_as_the_error_shape_callers_already_handle():
|
||||
"""Rule 156's second half: expiry surfaces as a NAMED failure.
|
||||
|
||||
A raw `DOMException: TimeoutError` reaches `apiErrorMessage(e, fallback)`
|
||||
as an object with no `body`, so every catch site in the app would print its
|
||||
generic fallback and the timeout would be invisible in exactly the
|
||||
situation it exists to expose. Rethrowing as `ApiError` is what makes the
|
||||
other ~330 call sites report it without being edited.
|
||||
"""
|
||||
src = CLIENT.read_text()
|
||||
assert 'e.name === "TimeoutError"' in src, (
|
||||
"request() must recognise a timeout specifically"
|
||||
)
|
||||
assert "new ApiError(CLIENT_TIMEOUT_STATUS" in src, (
|
||||
"a timeout must be rethrown as ApiError so apiErrorMessage can read it"
|
||||
)
|
||||
|
||||
|
||||
def test_a_deliberate_cancellation_is_not_reported_as_a_timeout():
|
||||
"""Only `TimeoutError` is converted, never `AbortError`.
|
||||
|
||||
A caller that cancelled its own request — a superseded search, a closed
|
||||
stream — must not have that surfaced to the user as a server failure. The
|
||||
guard is that the conversion is gated on the name, which the assertion
|
||||
above already pins; this states the intent so the gate is not "simplified"
|
||||
into catching every abort.
|
||||
"""
|
||||
src = CLIENT.read_text()
|
||||
convert = src[src.index("async function request<"):]
|
||||
convert = convert[:convert.index("\n}")]
|
||||
assert "AbortError" not in convert, (
|
||||
"request() must not convert AbortError — a deliberate cancellation is "
|
||||
"not a timeout"
|
||||
)
|
||||
Reference in New Issue
Block a user