mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
fix(favicon): short TTL for negative favicon cache entries
A failed favicon lookup was cached for a full week (same TTL as a success), so a transient miss (e.g. the icon not yet indexed upstream) blacklisted the domain for days. Cache negatives for 6 hours instead so the next email retries. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,10 @@ verbatim as the GitHub Release notes — so what you write here is what ships.
|
||||
button carries the subscribe/subscription hint only in its visible text (e.g.
|
||||
"Yes, subscribe me to this mailing list.") over an opaque tracking-redirect
|
||||
href — previously the link scored zero and the email was missed.
|
||||
- Sender favicons now recover from a transient miss: a failed favicon lookup is
|
||||
cached negatively for 6 hours instead of a full week, so a domain whose icon
|
||||
was momentarily unavailable (e.g. not yet indexed upstream) is retried on the
|
||||
next email instead of staying blank for days.
|
||||
|
||||
## [0.3.1] - 2026-05-25
|
||||
|
||||
|
||||
@@ -31,6 +31,13 @@ export const STATS_KEY = "stats:counters";
|
||||
/** Default TTL for a cached per-domain favicon (seconds). */
|
||||
export const ICON_TTL_SECONDS = 7 * 24 * 60 * 60; // 1 week
|
||||
|
||||
/**
|
||||
* TTL for a *negative* favicon cache entry (seconds). Kept short so a transient
|
||||
* miss (e.g. DuckDuckGo not having indexed the domain yet) self-heals within
|
||||
* hours instead of blacklisting the domain for a full week.
|
||||
*/
|
||||
export const ICON_NEGATIVE_TTL_SECONDS = 6 * 60 * 60; // 6 hours
|
||||
|
||||
/** Maximum accepted favicon size (bytes); larger responses are rejected. */
|
||||
export const MAX_ICON_BYTES = 100 * 1024; // 100 KB
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { server, createMockEnv } from "../test/setup";
|
||||
import {
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
extractEmailDomain,
|
||||
getCachedIcon,
|
||||
} from "./favicon-fetcher";
|
||||
import { MAX_ICON_BYTES } from "../config/constants";
|
||||
import { IconRepository } from "./icon-repository";
|
||||
import {
|
||||
ICON_NEGATIVE_TTL_SECONDS,
|
||||
ICON_TTL_SECONDS,
|
||||
MAX_ICON_BYTES,
|
||||
} from "../config/constants";
|
||||
|
||||
const iconKey = (domain: string) => `icon:${domain}`;
|
||||
import type { Env } from "../types";
|
||||
@@ -89,6 +94,45 @@ describe("cacheFaviconForDomain", () => {
|
||||
expect(await getCachedIcon("nope.test", env)).toBeNull();
|
||||
});
|
||||
|
||||
it("gives a negative entry a short TTL so transient misses self-heal", async () => {
|
||||
const env = createMockEnv() as unknown as Env;
|
||||
const put = vi.spyOn(IconRepository.prototype, "put");
|
||||
server.use(
|
||||
http.get("https://transient.test/favicon.ico", () =>
|
||||
HttpResponse.text("", { status: 404 }),
|
||||
),
|
||||
http.get("https://icons.duckduckgo.com/ip3/transient.test.ico", () =>
|
||||
HttpResponse.text("", { status: 404 }),
|
||||
),
|
||||
);
|
||||
|
||||
await cacheFaviconForDomain("transient.test", env);
|
||||
|
||||
expect(put).toHaveBeenCalledWith(
|
||||
"transient.test",
|
||||
expect.any(String),
|
||||
ICON_NEGATIVE_TTL_SECONDS,
|
||||
);
|
||||
put.mockRestore();
|
||||
});
|
||||
|
||||
it("gives a positive entry the full TTL", async () => {
|
||||
const env = createMockEnv() as unknown as Env;
|
||||
const put = vi.spyOn(IconRepository.prototype, "put");
|
||||
server.use(
|
||||
http.get("https://hit.test/favicon.ico", () => imageResponse(PNG)),
|
||||
);
|
||||
|
||||
await cacheFaviconForDomain("hit.test", env);
|
||||
|
||||
expect(put).toHaveBeenCalledWith(
|
||||
"hit.test",
|
||||
expect.any(String),
|
||||
ICON_TTL_SECONDS,
|
||||
);
|
||||
put.mockRestore();
|
||||
});
|
||||
|
||||
it("rejects oversized responses as negative", async () => {
|
||||
const env = createMockEnv() as unknown as Env;
|
||||
const big = new Uint8Array(MAX_ICON_BYTES + 1);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Env } from "../types";
|
||||
import {
|
||||
ICON_FETCH_TIMEOUT_MS,
|
||||
ICON_NEGATIVE_TTL_SECONDS,
|
||||
ICON_TTL_SECONDS,
|
||||
MAX_ICON_BYTES,
|
||||
} from "../config/constants";
|
||||
@@ -102,7 +103,8 @@ export async function cacheFaviconForDomain(
|
||||
}
|
||||
: { data: null, contentType: "" };
|
||||
|
||||
await repo.put(domain, JSON.stringify(record), ICON_TTL_SECONDS);
|
||||
const ttl = icon ? ICON_TTL_SECONDS : ICON_NEGATIVE_TTL_SECONDS;
|
||||
await repo.put(domain, JSON.stringify(record), ttl);
|
||||
} catch (error) {
|
||||
logger.warn("Favicon cache failed", { domain, error: String(error) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user