diff --git a/frontend/src/modules/feed/Feed.tsx b/frontend/src/modules/feed/Feed.tsx
index b6651642..a24f3650 100644
--- a/frontend/src/modules/feed/Feed.tsx
+++ b/frontend/src/modules/feed/Feed.tsx
@@ -624,7 +624,16 @@ export default function Feed({
{ytQuery.isLoading ? (
{t("feed.yt.searching")}
) : ytError && !ytItems.length ? (
- {ytError}
+ // 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.
+ void ytQuery.refetch()}
+ >
+ {ytError}
+
) : ytItems.length === 0 ? (
{t("feed.yt.noResults")}
) : (
diff --git a/frontend/src/modules/feed/Feed.ytLoadMore.dom.test.tsx b/frontend/src/modules/feed/Feed.ytLoadMore.dom.test.tsx
index 2eb865da..0686a889 100644
--- a/frontend/src/modules/feed/Feed.ytLoadMore.dom.test.tsx
+++ b/frontend/src/modules/feed/Feed.ytLoadMore.dom.test.tsx
@@ -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.