Skip to content

fix(validator): wait as long as the ecosystem does, and tell slow from dead - #110

Merged
hhkaos merged 1 commit into
mainfrom
fix/70-fetcher-timeouts
Nov 4, 2026
Merged

hhkaos merged 1 commit into
mainfrom
fix/70-fetcher-timeouts

Conversation

@hhkaos

@hhkaos hhkaos commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Closes #70.

validator.opentechevents.org called https://eventos.wiki/events.json — a registered adopter's feed — unreachable, while the registration bot that links here told that publisher the same feed was valid. Three tools, one feed, two answers. Measured: the feed answers 200 with ~554 KB in 23.6–51 s, consistently, well under maxBytes. Only the clock was wrong.

This leaves the validator aligned with the other two tools on 60 s, which is the argument the issue actually makes. The spec repo's daily health check (PR #43) and its adopter-registration bot (PR #46) both fetch with 60 s and retries, each after this same false negative was removed from it. At 5 s/10 s this endpoint publicly contradicted both.

The number, and why not a smaller one

60 s, for the hop and the chain alike. The issue floats 20–30 s per hop, but the feed's slow runs reach 51 s, so anything under 60 reopens the bug on a slower day. The normal case is one hop and must be able to spend the whole budget; a redirect chain still cannot multiply the wait, because each hop takes only what the chain has left.

Raising the number alone would have been a second bug

The issue asks that a dead host still fail fast and that the connect deadline not become the download one. fetch cannot express that — one AbortSignal covers the handshake, the origin thinking and the bytes alike — and I first wrote that limitation down as unavoidable. It is not. Measured against the runtime, a live host with a filtered port spent the entire 60 s and came back reading "slow": the mirror image of the false negative this PR is about, and a worse wait than the 10 s it replaced.

So each hop opens a bare TCP connection first, under its own 8 s budget (src/tcp-probe.ts, one extra subrequest per hop). The discrimination is stark: the adopter feed accepts a connection in 0.1 s and answers in 23.6 s, while nothing-is-listening is settled in 8. That case gets no-connection — never the word "slow", because there is nothing there to be slow.

The probe fails open. Anything other than a clean "nothing accepted a connection in time" is inconclusive and the fetch proceeds as if it had not run: a probe may shorten a hopeless wait, and must never invent a failure for a feed that would have loaded.

Two findings from probing the real runtime, neither visible to a fake:

  • connect() to an IP literal does not behave. An IPv4 literal timed out at 6 s where the hostname opened in 25 ms, and an IPv6 literal is refused outright. So the probe connects by hostname — after the SSRF checks, reaching nothing they do not already permit, and learning only what the fetch on the next line would learn anyway. The first design probed the already-validated address, which is nicer for SSRF and would have broken every IPv6-only feed.
  • close() is never awaited on a socket that never opened: it may not settle, and awaiting it hangs the request past the very budget the probe exists to impose.

One retry, and never for a timeout

A reset connection or a 502 from a shared host is not a verdict about a feed, so a hop gets a second attempt after a transport error or a 429/5xx — failures that arrive fast. 4xx is deterministic and is not asked twice. Deliberately narrower than the health check's and the bot's: those run unattended on a schedule, whereas here somebody is watching a spinner, and having waited the full budget once, waiting it again ends in "slow" 60 s later anyway.

Slow and dead must not read alike

Which is why the clock is not the whole fix, and where this meets #105. The timeout message now says the origin is slow and the feed is not invalid. /badge grows a slow state, kept apart from unreachable, because that image sits in a publisher's README and "unreachable" over a valid feed is an accusation. And #105's transport section still reports the 23.6 s, so the feed validates and is told what is wrong with how it is served — a slow healthy feed and a dead one do not end up telling the same story.

/health publishes the new budget, including connectTimeoutMs.

Verification

Against a local Worker, because this app's worst failure modes are invisible to vitest:

Case Before After Badge
Valid, slow adopter feed timeout @5.2 s 200, 568 KB @23.7 s valid
Nothing listening on the port 60.0 s, "slow" no-connection @8.2 s unreachable
Hostname does not resolve — 0.12 s unreachable
Answers nothing past 60 s unreachable slow, TTL 300 s slow

The page was driven in a real browser for both the slow feed — "Valid OTE document", plus "The server took 23.1 s to start answering" — and the unreachable one, with no boot errors. The retry and body.cancel() paths ran live too: a 503 took 1.01 s against a 0.18 s baseline. pnpm test and pnpm typecheck green; 96 worker tests (+14), including a guard that the preflight stays wired into the deps the Worker runs with, since losing it would cost a visitor 52 extra seconds and break no other test.

The shared-budget test was checked by breaking the fix on purpose (201 ms vs the 160 ms bound) rather than trusted for passing.

Not in scope, filed instead

Both created by this change, neither blocking it:

🤖 Generated with Claude Code

…m dead

Closes #70. `validator.opentechevents.org` called `https://eventos.wiki/events.json`
— a registered adopter's feed — `unreachable`, while the registration bot that
links here told that publisher the same feed was valid. Measured: the feed
answers 200 with ~554 KB in 23.6-51 s, consistently. Only the clock was wrong.

**60 s, for the hop and the chain alike.** Not a number chosen here: the spec
repo's daily health check and its adopter-registration bot both fetch with 60 s
and retries, each after this same false negative was removed from it (spec PRs
#43 and #46). The issue floats 20-30 s per hop, but the feed's slow runs reach
51 s, so anything under 60 reopens the bug on a slower day. The normal case is
one hop and must be able to spend the whole budget; a redirect chain still
cannot multiply the wait, since each hop takes only what the chain has left.

**A connect deadline that actually exists.** Raising the number alone replaces
one false negative with its mirror image: measured against the runtime, a live
host with a filtered port spent the entire 60 s and came back reading "slow".
`fetch` cannot separate the handshake from the bytes — one `AbortSignal` covers
both — so each hop now opens a bare TCP connection first, under its own 8 s
budget (`src/tcp-probe.ts`). The discrimination is stark: the adopter feed
accepts a connection in 0.1 s and answers in 23.6 s, while nothing-is-listening
is settled in 8. That case gets `no-connection`, never the word "slow".

The probe **fails open**: anything other than a clean "nothing accepted a
connection in time" is inconclusive and the fetch proceeds as if it had not
run. It may shorten a hopeless wait; it must never invent a failure for a feed
that would have loaded. Two findings from probing the real runtime, neither
visible to a fake: `connect()` to an IP literal does not behave (an IPv4
literal timed out at 6 s where the hostname opened in 25 ms; an IPv6 literal is
refused outright), so the probe connects by hostname, after the SSRF checks and
reaching nothing they do not already permit; and `close()` on a socket that
never opened is never awaited, because it may not settle and would hang the
request past the budget the probe exists to impose.

**One retry, never for a timeout.** A reset connection or a 502 from a shared
host is not a verdict about a feed, so a hop gets a second attempt after a
transport error or a 429/5xx — failures that arrive fast. 4xx is deterministic
and is not asked twice. Deliberately narrower than the other two tools': they
run unattended, while here somebody is watching a spinner, and having waited
the full budget once, waiting it again ends in "slow" 60 s later anyway.

**Slow and dead must not read alike**, which is why raising the clock is not
the whole fix. The timeout message says the origin is slow and the feed is not
invalid; `/badge` grows a `slow` state, apart from `unreachable`, because that
image sits in a publisher's README. And #105's transport section still reports
the 23.6 s: the feed now validates *and* is told what is wrong with how it is
served. The badge caption on the page listed only the old vocabulary and would
have lied about the new state.

Verified against a local Worker, since this app's worst failures are invisible
to vitest:

| Case | Before | After | Badge |
| --- | --- | --- | --- |
| Valid, slow adopter feed | `timeout` @5.2 s | 200, 568 KB @23.7 s | `valid` |
| Nothing listening on the port | 60.0 s, "slow" | `no-connection` @8.2 s | `unreachable` |
| Hostname does not resolve | — | 0.12 s | `unreachable` |
| Answers nothing past 60 s | `unreachable` | `slow`, TTL 300 s | `slow` |

The page was driven in a browser for both the slow feed (valid, with the 23.1 s
finding) and the unreachable one. The retry and `body.cancel()` paths ran live
too: a 503 took 1.01 s against a 0.18 s baseline.

Co-Authored-By: Claude Opus 5 <[email protected]>
@hhkaos
hhkaos merged commit ab448f3 into main Nov 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The fetcher's timeouts call a healthy adopter feed unreachable

1 participant