Repository navigation
fix(validator): wait as long as the ecosystem does, and tell slow from dead - #110
Merged
Merged
Conversation
…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]>
This was referenced Nov 4, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #70.
validator.opentechevents.orgcalledhttps://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 answers200with ~554 KB in 23.6–51 s, consistently, well undermaxBytes. 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.
fetchcannot express that — oneAbortSignalcovers 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 getsno-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 thefetchon 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.
/badgegrows aslowstate, kept apart fromunreachable, 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./healthpublishes the new budget, includingconnectTimeoutMs.Verification
Against a local Worker, because this app's worst failure modes are invisible to vitest:
timeout@5.2 s200, 568 KB @23.7 svalidno-connection@8.2 sunreachableunreachableunreachableslow, TTL 300 sslowThe 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 testandpnpm typecheckgreen; 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:
/badgeshould serve the cached verdict while revalidating. A 23.7 s cache miss is longer than GitHub's image proxy waits, which was not a problem at the old 10 s ceiling.🤖 Generated with Claude Code