commit c3f0e4bdc2b5068589d898ed2c0a51526fcccae2
parent 79bef42cb078cf903a38285d855607e37d7e0e07
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 26 Sep 2026 03:32:56 -0400
hub: the subs manifest — a 404 is an empty manifest, a real error is retried once and fails its archive (review fix 4)
Replaces 79ca2b0f's retry: false. A member with no live chat has no subs
manifest; its 404 now resolves to an empty one at once, the way posts
does, so the archive is ready without a second request. Any other error
(network, 5xx) keeps one retry (explicit on this query) and then fails the
archive like a failed manifest or page: the chip says failed, offers
Retry, and the "N of M archives answered" line names it. Before, a real
error read "ready" with that member's live chat silently missing.
A member's /subs/* is CORS-headed by its _headers, 404s included (checked
live on jeralyzer.pages.dev), so a missing file reads as a 404, not a
network error. e2e: a 500 is asked twice, fails with Retry, recovers.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 71 insertions(+), 20 deletions(-)
diff --git a/common/components/SearchDataContext.tsx b/common/components/SearchDataContext.tsx
@@ -27,6 +27,7 @@ import type { DisplaySummary } from "../lib/transcripts";
import type { Manifest, SubsManifest } from "../lib/manifest";
import type { PostsManifest } from "../lib/posts";
import { readerFor } from "../lib/archive/readers";
+import { ArchiveHttpError } from "../lib/archive/reader";
import {
DEFAULT_GROUP_FALLBACK_ID,
FALLBACK_GROUP,
@@ -344,16 +345,33 @@ export function MultiSiteDataProvider({
// contributions land in the search in one re-run. A posts 404 (no posts
// corpus) resolves to an empty manifest, not a failure.
//
- // The subs manifest is asked for ONCE (`retry: false`). A member with no live
- // chat has none, and its 404 is its answer: the client default (one retry,
- // after ~1 s) only held that archive at "loading" for another second, since
- // readiness waits for this query to settle and an error settles it.
+ // A subs 404 is the same answer — a member with no live chat has no subs
+ // manifest — so it resolves to an empty one at once. It used to throw, and
+ // the client's retry held that archive at "loading" for another second. Any
+ // OTHER error (network, 5xx) is retried once, then fails the archive like a
+ // failed manifest or page: its chip says so and offers Retry. (A member's
+ // `/subs/*` is CORS-headed by its _headers, 404s included, so a missing file
+ // reads as a 404 here, not as a network error.)
const subsQueries = useQueries({
queries: sites.map((s) => ({
queryKey: ["subs-manifest", s.origin],
- queryFn: () => readerFor(s.origin).readSubsSiteManifest(),
+ queryFn: () =>
+ readerFor(s.origin)
+ .readSubsSiteManifest()
+ .catch((err: unknown): SubsManifest => {
+ if (err instanceof ArchiveHttpError && err.status === 404) {
+ return {
+ version: 0,
+ channels: [],
+ totalCount: 0,
+ liveChatTotalCount: 0,
+ generatedAt: "",
+ };
+ }
+ throw err;
+ }),
enabled: inScope(s),
- retry: false,
+ retry: 1,
})),
});
const postsQueries = useQueries({
@@ -393,21 +411,23 @@ export function MultiSiteDataProvider({
const pages = pagesByOrigin.get(s.origin) ?? [];
const count = m?.data?.totalCount;
const withCount = count === undefined ? base : { ...base, count };
- // A subs manifest that errored still counts as settled (the archive just
- // has no live chat in the search); posts never error (404 → empty).
- const settled = (q: { isSuccess: boolean; isError: boolean; isFetching: boolean } | undefined) =>
- !!q && (q.isSuccess || (q.isError && !q.isFetching));
+ // Subs and posts manifests resolve a 404 to an empty manifest; posts never
+ // errors, and a subs error (after its retry) fails the archive below.
+ const subs = subsQueries[i];
if (
m?.isSuccess &&
pages.length === m.data.pageCount &&
pages.every((p) => p.isSuccess) &&
- settled(subsQueries[i]) &&
- settled(postsQueries[i])
+ subs?.isSuccess &&
+ postsQueries[i]?.isSuccess
) {
return { ...withCount, status: "ready" };
}
- const fetching = !!m?.isFetching || pages.some((p) => p.isFetching);
- const failed = m?.isError ? m : pages.find((p) => p.isError);
+ const fetching =
+ !!m?.isFetching || pages.some((p) => p.isFetching) || !!subs?.isFetching;
+ const failed = m?.isError
+ ? m
+ : (pages.find((p) => p.isError) ?? (subs?.isError ? subs : undefined));
if (failed && !fetching) {
return {
...withCount,
diff --git a/export/e2e-hub/federated-search.spec.ts b/export/e2e-hub/federated-search.spec.ts
@@ -8,7 +8,8 @@ import { expect, test, type Page, type Route } from "@playwright/test";
// still render), Retry, progressive readiness (a slow member does not hold the
// fast one's results back), and a card naming its source archive in text.
// Release 10 (L1): a member with no live chat is ready on its first subs
-// answer (a 404 is not retried); the official archives follow the homepage's
+// answer (a 404 is an empty manifest), and a subs manifest that cannot be read
+// fails its archive with Retry; the official archives follow the homepage's
// order (hub-summary.json's) on the cards and the chips, and an archive with no
// accent wears ONE colour — its homepage card's — on its card, its chip and its
// results; /ask searches only the archives the chips leave in.
@@ -67,12 +68,13 @@ function pageOf(m: Member) {
}
// Every request to a member origin is counted; `pages` decides how its
-// summaries page is answered, `subs` whether it has a subs manifest at all.
+// summaries page is answered, `subs` whether it has a subs manifest at all
+// ("missing": a 404) or cannot serve it ("error": a 500).
type PageMode = "ok" | "abort" | "hold";
type MemberMock = {
requests: string[];
pages: PageMode;
- subs: "ok" | "missing";
+ subs: "ok" | "missing" | "error";
held: Route[];
};
@@ -92,6 +94,9 @@ async function mockMember(page: Page, m: Member): Promise<MemberMock> {
}
return fulfillJson(route, pageOf(m));
}
+ if (url.pathname === "/subs/manifest.json" && mock.subs === "error") {
+ return route.fulfill({ status: 500, headers: CORS, body: "" });
+ }
if (url.pathname === "/subs/manifest.json" && mock.subs === "ok") {
return fulfillJson(route, {
version: 4,
@@ -304,9 +309,9 @@ test.describe("hub federated search — scope, per-archive state, attribution",
mocks.b.subs = "missing";
await page.goto("/");
- // Ready means every feed of the archive has settled, subs included. With a
- // retry, the 404 settled only on the second answer (~1 s later), so the
- // chip turned ready with two requests on record; now there is one.
+ // Ready means every feed of the archive is in, subs included. A 404 is an
+ // empty subs manifest, at once. It used to be an error, retried after ~1 s,
+ // so the chip turned ready with two requests on record; now there is one.
await expect(chip(page, B)).toHaveAttribute("data-status", "ready");
await expect(resultFrom(page, B)).toHaveCount(1);
const subsAsks = (m: MemberMock) =>
@@ -398,4 +403,30 @@ test.describe("hub federated search — scope, per-archive state, attribution",
await expect(ready).toBeVisible();
expect(mocks.b.requests).toContain("/summaries/page-0000.json");
});
+
+ test("a subs manifest that cannot be read is retried once, then fails its archive with Retry", async ({
+ page,
+ }) => {
+ const mocks = await setup(page);
+ mocks.b.subs = "error";
+ await page.goto("/");
+
+ await expect(chip(page, B)).toHaveAttribute("data-status", "failed");
+ await expect(
+ chip(page, B).getByRole("button", { name: "Retry Origin B" }),
+ ).toBeVisible();
+ const subsAsks = () =>
+ mocks.b.requests.filter((p) => p === "/subs/manifest.json").length;
+ expect(subsAsks()).toBe(2);
+ await expect(status(page)).toContainText("1 of 2 archives answered.");
+ await expect(resultFrom(page, A)).toHaveCount(1);
+ await expect(resultFrom(page, B)).toHaveCount(0);
+
+ // The member recovers; Retry reads its subs manifest again and it is in.
+ mocks.b.subs = "ok";
+ await chip(page, B).getByRole("button", { name: "Retry Origin B" }).click();
+ await expect(chip(page, B)).toHaveAttribute("data-status", "ready");
+ await expect(resultFrom(page, B)).toHaveCount(1);
+ await expect(status(page)).toHaveCount(0);
+ });
});