Fix: Cache AI fallback images for 90 days like every other image

They were kept for 24h so a later search could replace them, but that
regenerated them daily for no real gain. Searches where the AI image also
fails are still remembered for 24h as bad.webp. With one TTL for all images
again, the per-image ttl stored in KV and R2 is no longer needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 03:06:31 +00:00
co-authored by Claude Opus 5.5
parent 6fb64e9d7b
commit 865b9d951d
3 changed files with 15 additions and 17 deletions
+4 -4
View File
@@ -85,7 +85,7 @@ Literal slashes (`/`) and dots (`.`) in the URL path are **rejected** to prevent
- **Fragments (`#...`)** are never sent to the server by browsers
- **Double-encoded values** are decoded once — `%2520` becomes `%20` (literal), not a space
- Two queries that normalize to the same string share the same cached image
- **Nothing found?** If a search finds no image (or fewer than `i` working ones), an [AI image](#ai-images) of the query is served instead of `bad.webp`. It's kept for 24h, so a later search can still find a real one. `bad.webp` is only served if the AI image fails too
- **Nothing found?** If a search finds no image (or fewer than `i` working ones), an [AI image](#ai-images) of the query is served instead of `bad.webp`, and cached like any other image. `bad.webp` is only served if the AI image fails too
## Free Images
@@ -150,12 +150,12 @@ Limits change over time, so they aren't listed here. **Check the Rate Limits sec
- **New searches (cache misses)** are limited per IP per day, then `limit.webp` is served. `direct-img.link`, `free.direct-img.link` and `ai.direct-img.link` have separate counters, all reset at midnight UTC
- **Cache hits are unlimited** (within the burst limit)
- Searches that find nothing count toward the limit. The AI image served instead (or `bad.webp`, if that fails too) is kept for 24h
- Searches that find nothing count toward the limit. If the AI image served instead fails too, the failure is remembered for 24h (`bad.webp`)
- A Cloudflare WAF burst limit applies to all requests (HTTP 429). Its counters are approximate (not shared perfectly across Cloudflare servers), so bursts may slip slightly past it
## Caching
- Images are cached for **90 days**, except AI images served because a search found nothing, which are kept for **24 hours**
- Images are cached for **90 days**, including AI images
- After expiry, the next request triggers a fresh search
- Images are stored in their original format as fetched from source (AI images are WebP)
- A matching R2 lifecycle rule deletes stored objects after 90 days
@@ -267,7 +267,7 @@ Other keys share the namespace. They use uppercase prefixes, which can never col
| `AI:<src>:<i>:<query>` (e.g. `AI:auroraimg-6m:2:frog wizard`) | Same as above | 90 days |
| `OPENVERSE_TOKEN` | Openverse access token | Token lifetime minus 10 min |
An AI image served because a search found nothing is stored under the search's own key with `"ttl":86400` added to the value, and kept for 24h. A search where that also fails stores `{"t":…,"err":true}` for 24h.
An AI image served because a search found nothing is stored under the search's own key, like any other image. A search where that also fails stores `{"t":…,"err":true}` for 24h.
### Database: `SurrealDB` (Rate Limiting + Search Locks)
+10 -12
View File
@@ -6,8 +6,6 @@ import { aiImage, parseAiSource, DEFAULT_AI_SOURCE } from "./_utils/ai.js";
import * as LIMITS from "../assets/rate-limit.js";
const TTL_SECONDS = 90 * 24 * 60 * 60;
// Failed searches, and the AI images served in their place, are kept for a day so a later search can still succeed
const MISS_TTL_SECONDS = 24 * 60 * 60;
const MAX_INDEX = 20;
// How long a search may hold its query's lock; longer than the 20s image deadline plus the search itself
const LOCK_SECONDS = 45;
@@ -60,7 +58,7 @@ export async function onRequest(context) {
const obj = await env.R2_IMAGES.get(r2Key);
if (obj) {
const nowSec = Math.floor(Date.now() / 1000);
const remainingSec = Math.max(0, (cached.t + (cached.ttl || TTL_SECONDS)) - nowSec);
const remainingSec = Math.max(0, (cached.t + TTL_SECONDS) - nowSec);
return new Response(obj.body, { headers: imageHeaders(cached.ct, remainingSec * 1000) });
}
}
@@ -162,7 +160,7 @@ COMMIT TRANSACTION;`;
if (!await db(`RETURN lock:\`${keyHash}\`.exp > time::now();`).then(r => r.json()).then(d => d[0].result).catch(() => true)) break;
}
const obj = await env.R2_IMAGES.get(r2Key);
return obj ? new Response(obj.body, { headers: imageHeaders(obj.httpMetadata.contentType, (+obj.customMetadata?.ttl || TTL_SECONDS) * 1000) }) : asset("bad.webp");
return obj ? new Response(obj.body, { headers: imageHeaders(obj.httpMetadata.contentType, TTL_SECONDS * 1000) }) : asset("bad.webp");
}
if (count > limit) {
@@ -175,20 +173,20 @@ COMMIT TRANSACTION;`;
// Called once the result (image or error) is stored, so waiters serve it right away instead of at lock expiry
const release = () => locked && context.waitUntil(db(`DELETE lock:\`${keyHash}\`;`).catch(() => {}));
// ttl is stored with the image (KV and R2) so cache hits and deduped waiters send a matching max-age
const store = async ({ buffer, contentType }, ttl = TTL_SECONDS) => {
await env.R2_IMAGES.put(r2Key, buffer, { httpMetadata: { contentType }, customMetadata: { ttl: `${ttl}` } });
await env.DIRECT_IMG_CACHE.put(cacheKey, JSON.stringify({ t: Math.floor(Date.now() / 1000), ct: contentType, ...(ttl !== TTL_SECONDS && { ttl }) }), { expirationTtl: ttl });
const store = async ({ buffer, contentType }) => {
await env.R2_IMAGES.put(r2Key, buffer, { httpMetadata: { contentType } });
await env.DIRECT_IMG_CACHE.put(cacheKey, JSON.stringify({ t: Math.floor(Date.now() / 1000), ct: contentType }), { expirationTtl: TTL_SECONDS });
release();
return new Response(buffer, { headers: imageHeaders(contentType, ttl * 1000) });
return new Response(buffer, { headers: imageHeaders(contentType, TTL_SECONDS * 1000) });
};
// Nothing found: serve an AI image of the query instead of bad.webp. bad.webp is only left for when that fails too.
// Nothing found: serve an AI image of the query instead, cached like any other image. bad.webp (remembered for 24h)
// is only left for when that fails too.
const fail = async (t, m, tag, p) => {
const fallback = await aiImage(DEFAULT_AI_SOURCE, query, i, env);
context.waitUntil(notify(env, { title: fallback.success ? `${t}, Served AI Image` : t, message: fallback.success ? m : `${m}\nAI fallback failed: ${fallback.reason}`, tags: tag, priority: p }));
if (fallback.success) return await store(fallback, MISS_TTL_SECONDS);
await env.DIRECT_IMG_CACHE.put(cacheKey, JSON.stringify({ t: Math.floor(Date.now() / 1000), err: true }), { expirationTtl: MISS_TTL_SECONDS });
if (fallback.success) return await store(fallback);
await env.DIRECT_IMG_CACHE.put(cacheKey, JSON.stringify({ t: Math.floor(Date.now() / 1000), err: true }), { expirationTtl: 86400 });
release();
return asset("bad.webp");
};
+1 -1
View File
@@ -128,7 +128,7 @@ Use images to complement your responses, powered by Brave.</code></pre>
<section>
<div class="card">
<h3>Caching</h3>
<p>Images are cached for 90 days. Subsequent requests for the same query are served instantly. Failed searches are remembered for 24 hours.</p>
<p>Images are cached for 90 days. Subsequent requests for the same query are served instantly. Searches that find nothing serve an AI image instead, cached the same way.</p>
</div>
</section>
</main>