fix: stabilize OG image generation under bot crawler load - #2089
fix: stabilize OG image generation under bot crawler load#2089gaspergrom wants to merge 3 commits into
Conversation
… under bot load IN-1200 Implement multi-layered approach to stabilize OG image generation under concurrent bot crawler requests: 1. Timeout handling (8s): Prevents slow Satori/Resvg rendering from blocking 2. Bot rate limiting (5 req/s per bot): Targets bingbot, PetalBot, Amzn-SearchBot, et al. 3. Response caching (24h): Reduces rendering load from repeated requests 4. Collection validation: Prevents 500s on non-existent collections (mirrors project middleware) 5. Improved fallback: Enhanced error logging and timeout detection These changes address the root cause (rendering timeouts) while also reducing load via caching and rate limits. Cache control headers enable CDN/browser caching of generated images. Suggested investigation items from ticket remain out-of-scope: - robots.txt bot exclusion (infrastructure change) - CDN-level edge caching (infra configuration) - Rendering service resource limits (infrastructure config) This fix focuses on application-level stabilization that can be deployed immediately. Signed-off-by: Gašper Grom <gasper.grom@gmail.com>
There was a problem hiding this comment.
Pull request overview
Adds safeguards intended to stabilize OG image generation under crawler load.
Changes:
- Adds timeout, bot throttling, and cache handling.
- Validates collection OG requests.
- Expands fallback error logging.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
frontend/setup/og-image.ts |
Configures OG cache duration. |
frontend/server/plugins/og-image-fallback.ts |
Enhances fallback logging. |
frontend/server/middleware/og-image-timeout.ts |
Introduces render timeout handling. |
frontend/server/middleware/og-image-collection.ts |
Validates collection slugs. |
frontend/server/middleware/og-image-cache-headers.ts |
Adds response cache headers. |
frontend/server/middleware/og-image-bot-limiter.ts |
Rate-limits known crawlers. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| cache: { | ||
| maxAge: 60 * 60 * 24, // 24 hours in seconds | ||
| }, |
| const timeoutId = setTimeout(() => { | ||
| timeoutController.abort(); | ||
| }, OG_IMAGE_RENDER_TIMEOUT_MS); | ||
|
|
||
| // Store timeout controller on the event for potential use in handlers | ||
| (event.node as any).__ogImageTimeoutAbort = timeoutController; |
| error.message.includes('AbortError')); | ||
|
|
||
| const errorType = isTimeout ? 'TIMEOUT' : 'ERROR'; | ||
| const severity = isTimeout ? 'WARN' : 'ERROR'; |
| setHeader(event, 'Vary', 'User-Agent'); | ||
|
|
||
| // Log cache headers being applied | ||
| onBeforeSendResponse(async (context) => { |
| const res = await fetchFromTinybird<Record<string, unknown>[]>( | ||
| '/v0/pipes/collections_list.json', | ||
| { | ||
| slug, | ||
| details: true, | ||
| } |
| setHeader( | ||
| event, | ||
| 'Cache-Control', | ||
| `public, max-age=${CACHE_MAX_AGE_SECONDS}, stale-while-revalidate=${STALE_WHILE_REVALIDATE}` | ||
| ); |
| return null; | ||
| } | ||
|
|
||
| function isRateLimited(botId: string): boolean { |
Signed-off-by: Gašper Grom <gasper.grom@gmail.com>
…ng IN-1200 - fix nonexistent onBeforeSendResponse hook in og-image-cache-headers.ts, which threw on every OG image request and silently fell back to the static image; replace with a writeHead patch that sets Cache-Control by final status code - fix og-image.ts cache config using the wrong nuxt-og-image schema (cache.maxAge instead of defaults.cacheMaxAgeSeconds), which was a no-op - fix og-image-timeout.ts only logging on timeout instead of actually sending the static fallback response - add periodic eviction of expired entries in og-image-bot-limiter.ts's request-tracking map - add test coverage for all four middleware files Signed-off-by: Gašper Grom <gasper.grom@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (6)
frontend/server/middleware/og-image-bot-limiter.ts:70
- The new limiter's boundary behavior is untested: the suite sends only one request per recognized bot and never verifies that requests 1–5 pass, request 6 returns 429, and a new window resets the quota. Add a state-isolated test for those transitions so off-by-one or reset regressions are caught.
// Check if we've exceeded the limit
if (tracked.count >= RATE_LIMIT_CONFIG.requestsPerSecond) {
return true;
frontend/server/middleware/og-image-collection.ts:30
- This validator uses Tinybird, but the renderer fetches
/api/collection/:slug, whose source of truth isCommunityCollectionRepository.findBySlug(server/api/collection/[slug]/index.ts:35-40). Any collection missing or lagging in Tinybird—including DB-backed community collections—will be falsely redirected even though rendering can succeed. Validate against the same PostgreSQL source of truth, ideally with a lightweight shared existence query.
const res = await fetchFromTinybird<Record<string, unknown>[]>(
'/v0/pipes/collections_list.json',
frontend/server/middleware/og-image-timeout.ts:24
- Nuxt auto-registers server middleware in filename order, so this timer starts only after
og-image-collection.tsandog-image-project.tsfinish. Tinybird acquisition alone can wait 10 seconds (server/data/tinybird/tinybird.ts:10,149-153), meaning requests can already exceed the advertised 8-second timeout before this line runs. Register the timeout middleware before validation, for example with an ordering prefix.
const timeoutId = setTimeout(() => {
frontend/server/middleware/og-image-cache-headers.ts:36
- The rendered image does not vary by user agent, so this header creates a separate CDN/browser cache entry for every crawler UA and reduces the cache hit rate this PR is intended to improve. It can also overwrite an existing
Varyvalue. RemoveVary: User-Agentand update the corresponding test expectation.
res.setHeader('Vary', 'User-Agent');
frontend/server/plugins/og-image-fallback.ts:26
- The previous logging passed the error object itself, but this now logs only its message, dropping the stack trace and error metadata needed to diagnose unexpected renderer failures. Preserve the structured error alongside the formatted context.
console.error(message);
frontend/server/middleware/og-image-bot-limiter.test.ts:50
- This user agent matches none of
BOT_PATTERNS, so the handler exits through the non-bot branch and the test does not exercise an initial tracked bot request. Use a recognized bot that is not reused by the later tests.
(global.getHeader as any).mockReturnValue('unique-bot-' + Math.random());
| import { sendRedirect } from 'h3'; | ||
| import { fetchFromTinybird } from '~~/server/data/tinybird/tinybird'; | ||
|
|
||
| const OG_IMAGE_COLLECTION_PREFIX = '/__og-image__/image/collection/'; |
Summary
Implements multi-layered approach to fix OG image 500 errors under concurrent bot crawler load (IN-1200):
Files Changed
frontend/server/middleware/og-image-timeout.ts(new)frontend/server/middleware/og-image-bot-limiter.ts(new)frontend/server/middleware/og-image-cache-headers.ts(new)frontend/server/middleware/og-image-collection.ts(new)frontend/server/plugins/og-image-fallback.ts(enhanced)frontend/setup/og-image.ts(cache config added)How to Test
Out of Scope
These infrastructure changes are being tracked separately.