fix(feed): a live search whose first page failed offers Retry where trying again can help

Search YouTube again with the same term read the failure from the cache and sent nothing, so a busy or unreachable YouTube left the search on its error until the term changed. The first page's failure is now the error state with Retry, offered only where a retry can answer (not the day's cap, the feature turned off or the shared budget).
This commit is contained in:
2026-10-25 14:05:07 +01:00
parent ee4a5b46f8
commit 9d6d7525e5
2 changed files with 42 additions and 1 deletions
+10 -1
View File
@@ -624,7 +624,16 @@ export default function Feed({
{ytQuery.isLoading ? (
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
) : ytError && !ytItems.length ? (
<div className="py-16 text-center text-muted max-w-md mx-auto">{ytError}</div>
// A first page that failed is said as the error it is, with Retry where trying again can
// help (YouTube busy or unreachable): Search YouTube again with the same term reads the
// failure from the cache and sends nothing.
<StateMessage
tone="error"
className="max-w-md mx-auto"
onRetry={ytFinal ? undefined : () => void ytQuery.refetch()}
>
{ytError}
</StateMessage>
) : ytItems.length === 0 ? (
<div className="py-16 text-center text-muted">{t("feed.yt.noResults")}</div>
) : (
@@ -146,6 +146,38 @@ describe("Feed — a live search's Load more that fails", () => {
});
});
describe("Feed — a live search whose first page fails", () => {
// Search YouTube again with the same term reads the cached failure: Retry is the way to ask again.
it("offers Retry where trying again can help, and reads the page on it", async () => {
const search = vi
.spyOn(api, "searchYoutube")
.mockRejectedValueOnce(
new HttpError(429, "Too many searches", { code: "refused", reason: "search_busy" }),
)
.mockResolvedValueOnce({ items: [hit("a")], next_cursor: null, source: "scrape" } as never);
mount();
expect(await screen.findByText("Too many searches")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "common.retry" }));
await waitFor(() =>
expect(screen.getByRole("list", { name: "results" }).textContent).toBe("hit a (new)"),
);
expect(search).toHaveBeenCalledTimes(2);
});
it("offers no Retry after a refusal a retry cannot answer", async () => {
vi.spyOn(api, "searchYoutube").mockRejectedValue(
new HttpError(429, "Today's limit", {
code: "refused",
reason: "search_daily_limit",
limit: 2,
}),
);
mount();
expect(await screen.findByText("Today's limit")).toBeTruthy();
expect(screen.queryByRole("button", { name: "common.retry" })).toBeNull();
});
});
describe("Feed — a live search's Load more on its way", () => {
// Marked in this session, then changed back elsewhere: a search read after the mark carries the
// server's word.