Files
FabledScribe/tests/test_frontend_request_deadlines.py
bvandeusenandClaude Opus 5 e029a7db64
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
fix(frontend): every request carries a deadline, and expiry arrives as an error callers already handle (#3412)
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
2026-09-02 14:59:41 -04:00

129 lines
5.0 KiB
Python

"""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"
)