diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e8558..27478a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ All notable changes are listed here. The format follows [Keep a Changelog](https - The image leaves out the app's TypeScript sources and their tests, which the server never read. `import-kuma` keeps its own. +### Fixed + +- A redirect to the same host is followed over the same connection, as in a browser. Each hop opened a new connection and paid its TCP and TLS handshakes again, so a site whose address redirects read slower than it is. +- HTTP checks no longer send `cache-control: no-cache` and `pragma: no-cache`. Some caches obey them and visitors never send them, so a site behind such a cache was measured on its uncached page. A monitor that should reach the server behind the cache can send the header in its `headers`. + ## 0.5.0 - 2026-10-04 ### Added diff --git a/README.md b/README.md index 1d3697e..8243f6a 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ The checker runs inside the server process: 1. Every `checkIntervalSeconds`, it runs every monitor that is due, with a 10-second timeout. After a restart a monitor is due when its interval has passed since its last check, not at once. A monitor whose last check is still waiting on its timeout is left to finish; the others go ahead. A monitor with `intervalSeconds` runs less often. The checks of one round are spread over the first three quarters of the interval (45 seconds at most) so that they do not slow each other down. 2. An HTTP check passes on a 2xx response, or on the exact `expectStatus` if you set one, and, with a `keyword`, only when the body contains it (or does not, with `keywordMode: absent`). Bodies are read up to 1 MB. - HTTP, TCP and certificate checks open a connection of their own each time and time each step: the DNS lookup, the TCP connection, the TLS handshake and, for HTTP, the wait for the first byte of the response. A host's addresses are tried side by side, the next one starting a quarter second after the last, and the first to answer is used. The list of checks behind a bar shows the steps; a check that timed out says where it stopped. + HTTP, TCP and certificate checks open a connection of their own each time and time each step: the DNS lookup, the TCP connection, the TLS handshake and, for HTTP, the wait for the first byte of the response. A host's addresses are tried side by side, the next one starting a quarter second after the last, and the first to answer is used. Redirects count, and a hop to the same host goes over the same connection, as in a browser. The request sends no cache headers, so a cache in front of the site answers it as it answers a visitor; a monitor that should reach the server behind it can send `Cache-Control: no-cache` in its `headers`. The list of checks behind a bar shows the steps; a check that timed out says where it stopped. 3. A monitor becomes **down** after 2 failed checks in a row and **up** again after 1 successful check. With a `slowThresholdMs`, it becomes **slow** after 2 successful checks over the threshold and back to normal after 1 under it. Each change of state sends one alert to every destination of the site. With `repeatMinutes`, a monitor that stays down sends a "still down" notice at that interval. 4. A monitor going down opens an incident on the page; the first success after it resolves the incident. 5. Checks made inside a maintenance window are stored with a flag. The page shows them in grey and leaves them out of every total, and they change no state and send no alert. diff --git a/src/lib/checker.test.ts b/src/lib/checker.test.ts index bd7d8b7..6b12c02 100644 --- a/src/lib/checker.test.ts +++ b/src/lib/checker.test.ts @@ -18,6 +18,8 @@ import { parseConfig } from "./config"; let server: Server; let base: string; +/** Connections the test server has accepted. */ +let connections = 0; beforeAll(async () => { server = createServer((req, res) => { @@ -30,6 +32,20 @@ beforeAll(async () => { res.writeHead(500).end("boom"); } else if (req.url === "/redirect") { res.writeHead(302, { Location: "/ok" }).end(); + } else if (req.url === "/redirect-with-body") { + res.writeHead(301, { Location: "/ok" }).end("Moved to /ok"); + } else if (req.url === "/redirect-stalls") { + // Says more body is coming than ever arrives. + res.writeHead(302, { Location: "/ok", "Content-Length": "100" }); + res.write("Moved"); + } else if (req.url === "/see-other") { + res.writeHead(303, { Location: "/echo" }).end(); + } else if (req.url === "/headers") { + res + .writeHead(200) + .end( + `${req.headers["cache-control"] ?? "-"} ${req.headers.pragma ?? "-"}`, + ); } else if (req.url === "/slow") { setTimeout(() => res.writeHead(200).end("late"), 500); } else if (req.url === "/health") { @@ -43,6 +59,7 @@ beforeAll(async () => { } }); }); + server.on("connection", () => connections++); await new Promise((resolve) => server.listen(0, resolve)); const address = server.address(); if (address === null || typeof address === "string") @@ -82,6 +99,39 @@ describe("runCheck", () => { expect(outcome.statusCode).toBe(200); }); + it("follows a redirect to the same host over the same connection", async () => { + for (const path of ["/redirect", "/redirect-with-body"]) { + const before = connections; + const outcome = await runCheck({ url: `${base}${path}` }); + expect(outcome).toMatchObject({ ok: true, statusCode: 200 }); + expect(connections - before).toBe(1); + } + }); + + it("follows at once a redirect whose body stalls", async () => { + const outcome = await runCheck({ + url: `${base}/redirect-stalls`, + timeoutMs: 3000, + }); + expect(outcome).toMatchObject({ ok: true, statusCode: 200 }); + expect(outcome.latencyMs).toBeLessThan(1500); + }); + + it("continues a POST as a GET without the body after a 303", async () => { + const outcome = await runCheck({ + url: `${base}/see-other`, + method: "POST", + body: "payload", + keyword: "GET - -", + }); + expect(outcome).toMatchObject({ ok: true, error: null }); + }); + + it("asks for nothing special from caches in front of the site", async () => { + const outcome = await runCheck({ url: `${base}/headers`, keyword: "- -" }); + expect(outcome).toMatchObject({ ok: true, error: null }); + }); + it("asserts the redirect itself when expectStatus is 3xx", async () => { const outcome = await runCheck({ url: `${base}/redirect`, diff --git a/src/lib/checker.ts b/src/lib/checker.ts index 82a21ed..09d652c 100644 --- a/src/lib/checker.ts +++ b/src/lib/checker.ts @@ -86,6 +86,16 @@ export interface CheckOutcome { /** Bodies are read up to this much when a keyword is set. */ const MAX_BODY_BYTES = 1024 * 1024; +/** Redirect hops followed before the check gives up, as fetch's own limit. */ +const MAX_REDIRECTS = 20; +/** A redirect's body is read up to this much, so its connection carries the next hop. */ +const MAX_REDIRECT_BODY_BYTES = 64 * 1024; +/** + * How long a redirect's body may take to arrive. A browser follows at + * once and drains on the side; one whose body stalls is dropped after + * this, and the next hop opens its own connection. + */ +const REDIRECT_DRAIN_MS = 250; const DAY_MS = 24 * 60 * 60 * 1000; /** The check a monitor's configuration describes. Heartbeats have none. */ @@ -173,6 +183,105 @@ async function readBody(res: Response): Promise { return text + decoder.decode(); } +/** + * Reads a redirect's body to its end and drops it, as a browser does, so + * its connection is free for the next hop. A cancelled body takes the + * connection down with it, and fetch's own redirects did that: the next + * hop to the same host paid a new connection and handshake that a + * browser does not. A body over the limit, or one that has not ended + * within REDIRECT_DRAIN_MS, is cancelled after all. + */ +async function discardBody(res: Response): Promise { + if (!res.body) return; + const reader = res.body.getReader(); + let timer: ReturnType | undefined; + const late = new Promise((resolve) => { + timer = setTimeout(() => resolve(null), REDIRECT_DRAIN_MS); + }); + let bytes = 0; + try { + for (;;) { + const next = await Promise.race([reader.read(), late]); + if (next === null) break; + if (next.done) { + // The connection goes back to the pool a turn of the event loop + // after the body ends; a hop sent sooner opens another. + await new Promise((resolve) => setImmediate(resolve)); + return; + } + bytes += next.value.byteLength; + if (bytes > MAX_REDIRECT_BODY_BYTES) break; + } + void reader.cancel().catch(() => {}); + } catch { + // A broken connection: the next hop opens its own, or times out. + } finally { + clearTimeout(timer); + } +} + +/** + * Fetches, following redirects by hand the way a browser does: a hop to + * a host already reached goes over the same connection, credentials stay + * with their own origin, and a 303 (or a 301 or 302 after a POST) + * continues as a GET without the body. + */ +async function fetchFollowing( + url: string, + init: RequestInit & { method: string }, + follow: boolean, +): Promise { + let target = url; + let method = init.method; + let body = init.body; + let headers = new Headers(init.headers); + for (let hop = 0; ; hop++) { + const res = await fetch(target, { + ...init, + headers, + method, + body, + redirect: "manual", + } as RequestInit); + const location = res.headers.get("location"); + const redirect = + follow && + location !== null && + [301, 302, 303, 307, 308].includes(res.status); + if (!redirect) return res; + if (hop >= MAX_REDIRECTS) { + void res.body?.cancel().catch(() => {}); + throw new Error("too many redirects"); + } + await discardBody(res); + // Out of time while the body came: the next hop would fail anyway. + if (init.signal?.aborted) throw init.signal.reason; + const next = new URL(location, target); + if (next.origin !== new URL(target).origin) { + headers = new Headers(headers); + for (const name of ["authorization", "proxy-authorization", "cookie"]) + headers.delete(name); + } + target = next.toString(); + if ( + (res.status === 303 && method !== "HEAD") || + ((res.status === 301 || res.status === 302) && method === "POST") + ) { + method = "GET"; + body = undefined; + headers = new Headers(headers); + for (const name of [ + "content-type", + "content-length", + "content-encoding", + "content-language", + "content-location", + ]) + headers.delete(name); + } + } +} + /** The code on a socket or DNS error, or its message. */ function errorCode(err: unknown): string { if (err instanceof Error) { @@ -241,18 +350,25 @@ async function httpCheck(spec: CheckSpec): Promise { }; }; try { - const res = await fetch(spec.url, { - method, - headers: spec.headers ?? {}, - body: - spec.body && method !== "GET" && method !== "HEAD" - ? spec.body - : undefined, - signal: AbortSignal.timeout(timeoutMs), - redirect: expectsRedirect ? "manual" : "follow", - cache: "no-store", - ...(agent ? { dispatcher: agent } : {}), - } as RequestInit); + const res = await fetchFollowing( + spec.url, + { + method, + headers: spec.headers ?? {}, + body: + spec.body && method !== "GET" && method !== "HEAD" + ? spec.body + : undefined, + signal: AbortSignal.timeout(timeoutMs), + // No cache mode: Node's fetch keeps no cache, and "no-store" only + // adds `cache-control: no-cache` and `pragma: no-cache`, which some + // caches in front of a site obey and a visitor never sends, so the + // check read the uncached page. A monitor that wants that sends + // the header itself. + ...(agent ? { dispatcher: agent } : {}), + } as RequestInit & { method: string }, + !expectsRedirect, + ); const latencyMs = since(start); headersMs = latencyMs; const statusOk =