Skip to content

Commit 82a405b

Browse files
committed
fix: enhance caching logic to differentiate between transient failures and permanent not found results
1 parent 988da79 commit 82a405b

File tree

3 files changed

+67
-11
lines changed

3 files changed

+67
-11
lines changed

lib/cache.test.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,11 @@ describe("cached assets", () => {
9797
expect(stored?.url).toBe("https://cdn/new.webp");
9898
});
9999

100-
it("retries when null is cached (treats null as miss)", async () => {
100+
it("retries transient failures (null without notFound flag)", async () => {
101101
const indexKey = ns("test", "asset4");
102102
const lockKey = ns("lock", "test", "asset4");
103103

104-
// Pre-seed cache with null result (simulating previous failure)
104+
// Pre-seed cache with null result WITHOUT notFound (simulating transient failure)
105105
const { redis } = await import("@/lib/redis");
106106
await redis.set(indexKey, {
107107
url: null,
@@ -124,25 +124,55 @@ describe("cached assets", () => {
124124
expect(produceCalled).toBe(true);
125125
});
126126

127-
it("writes null to cache to prevent concurrent retries", async () => {
127+
it("does NOT retry permanent not found (null with notFound=true)", async () => {
128128
const indexKey = ns("test", "asset5");
129129
const lockKey = ns("lock", "test", "asset5");
130130

131+
// Pre-seed cache with permanent not found
132+
const { redis } = await import("@/lib/redis");
133+
await redis.set(indexKey, {
134+
url: null,
135+
notFound: true,
136+
expiresAtMs: Date.now() + 1000,
137+
});
138+
139+
let produceCalled = false;
140+
const result = await getOrCreateCachedAsset<{ source: string }>({
141+
indexKey,
142+
lockKey,
143+
ttlSeconds: 60,
144+
produceAndUpload: async () => {
145+
produceCalled = true;
146+
return { url: "https://cdn/should-not-call.webp" };
147+
},
148+
});
149+
150+
// Should NOT retry and return null immediately
151+
expect(result).toEqual({ url: null });
152+
expect(produceCalled).toBe(false);
153+
});
154+
155+
it("caches notFound flag when returned by producer", async () => {
156+
const indexKey = ns("test", "asset6");
157+
const lockKey = ns("lock", "test", "asset6");
158+
131159
const result = await getOrCreateCachedAsset<{ source: string }>({
132160
indexKey,
133161
lockKey,
134162
ttlSeconds: 60,
135-
produceAndUpload: async () => ({ url: null }),
163+
produceAndUpload: async () => ({ url: null, notFound: true }),
136164
});
137165

138166
expect(result).toEqual({ url: null });
139167

140-
// Null should still be written to cache
168+
// notFound flag should be stored in cache
141169
const { redis } = await import("@/lib/redis");
142170
const stored = (await redis.get(indexKey)) as {
143171
url?: string | null;
172+
notFound?: boolean;
144173
} | null;
145174
expect(stored?.url).toBe(null);
175+
expect(stored?.notFound).toBe(true);
146176
});
147177

148178
it("schedules blob deletion with grace period beyond Redis TTL", async () => {

lib/cache.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ type CachedAssetOptions<TProduceMeta extends Record<string, unknown>> = {
143143
produceAndUpload: () => Promise<{
144144
url: string | null;
145145
key?: string;
146+
notFound?: boolean; // true if asset permanently doesn't exist (don't retry)
146147
metrics?: TProduceMeta;
147148
}>;
148149
/**
@@ -166,15 +167,24 @@ export async function getOrCreateCachedAsset<T extends Record<string, unknown>>(
166167

167168
// 1) Check index
168169
try {
169-
const raw = (await redis.get(indexKey)) as { url?: unknown } | null;
170+
const raw = (await redis.get(indexKey)) as {
171+
url?: unknown;
172+
notFound?: unknown;
173+
} | null;
170174
if (raw && typeof raw === "object") {
171175
const cachedUrl = (raw as { url?: unknown }).url;
176+
const cachedNotFound = (raw as { notFound?: unknown }).notFound;
177+
172178
if (typeof cachedUrl === "string") {
173179
return { url: cachedUrl };
174180
}
175-
// Treat null as cache miss - allows retry on transient failures
176-
// Null will still be written to cache with lock to prevent concurrent retries
181+
// Only retry null if it's NOT marked as permanently not found
177182
if (cachedUrl === null) {
183+
if (cachedNotFound === true) {
184+
// Permanent not found - don't retry
185+
return { url: null };
186+
}
187+
// Transient failure or legacy null - retry
178188
console.debug(
179189
`[cache] null result in cache ${indexKey}, treating as miss for retry`,
180190
);
@@ -244,7 +254,12 @@ export async function getOrCreateCachedAsset<T extends Record<string, unknown>>(
244254
const pipeline = redis.pipeline();
245255
pipeline.set(
246256
indexKey,
247-
{ url: produced.url, key: produced.key, expiresAtMs },
257+
{
258+
url: produced.url,
259+
key: produced.key,
260+
notFound: produced.notFound ?? undefined, // Store notFound flag if present
261+
expiresAtMs,
262+
},
248263
{ ex: ttlSeconds },
249264
);
250265
if (purgeQueue && produced.url) {

server/services/favicon.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export async function getOrCreateFaviconBlobUrl(
3232
purgeQueue: "favicon",
3333
produceAndUpload: async () => {
3434
const sources = buildSources(domain);
35+
let allNotFound = true; // Track if all sources returned 404/not found
36+
3537
for (const src of sources) {
3638
try {
3739
const res = await fetchWithTimeout(
@@ -46,7 +48,13 @@ export async function getOrCreateFaviconBlobUrl(
4648
},
4749
{ timeoutMs: REQUEST_TIMEOUT_MS },
4850
);
49-
if (!res.ok) continue;
51+
if (!res.ok) {
52+
// Track if this was a 404 (not found) vs other error
53+
if (res.status !== 404) {
54+
allNotFound = false; // Server error, timeout, etc. - not a true "not found"
55+
}
56+
continue;
57+
}
5058
const contentType = res.headers.get("content-type");
5159
const ab = await res.arrayBuffer();
5260
const buf = Buffer.from(ab);
@@ -81,10 +89,13 @@ export async function getOrCreateFaviconBlobUrl(
8189
},
8290
};
8391
} catch {
92+
// Network error, timeout, etc. - not a true "not found"
93+
allNotFound = false;
8494
// try next source
8595
}
8696
}
87-
return { url: null };
97+
// Return null with notFound flag if ALL sources returned 404
98+
return { url: null, notFound: allNotFound };
8899
},
89100
});
90101
}

0 commit comments

Comments
 (0)