From 865b9d951d5aa29b64d495f13dd939cd3d8af5f0 Mon Sep 17 00:00:00 2001 From: "planetrenox@protonmail.com" Date: Mon, 12 Oct 2026 03:06:31 +0000 Subject: [PATCH] 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 --- README.md | 8 ++++---- functions/[[path]].js | 22 ++++++++++------------ index.html | 2 +- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 32852a2..2b2cf99 100644 --- a/README.md +++ b/README.md @@ -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:::` (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) diff --git a/functions/[[path]].js b/functions/[[path]].js index 68cc060..3607810 100644 --- a/functions/[[path]].js +++ b/functions/[[path]].js @@ -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"); }; diff --git a/index.html b/index.html index b62188b..ae2baed 100644 --- a/index.html +++ b/index.html @@ -128,7 +128,7 @@ Use images to complement your responses, powered by Brave.

Caching

-

Images are cached for 90 days. Subsequent requests for the same query are served instantly. Failed searches are remembered for 24 hours.

+

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.