Skip to content

Handle tenant API auth failures gracefully and drop Server Actions - #1464

Open
camilb wants to merge 2 commits into
mainfrom
fix/api-auth-errors-and-server-actions
Open

Handle tenant API auth failures gracefully and drop Server Actions#1464
camilb wants to merge 2 commits into
mainfrom
fix/api-auth-errors-and-server-actions

Conversation

@camilb

@camilb camilb commented Jul 17, 2026

Copy link
Copy Markdown
Member

Problem

Production pods (theme-nextjs-bea, -preview) log a flood of API Error (401): Unauthorized entries whenever a tenant's access token is revoked or rotated:

  • every request for the affected newsroom crashes the RSC render into a 500,
  • each error line is ~6.5KB (the SDK attaches the full API response headers, including the CSP policy),
  • nothing in the logs identifies which newsroom/host is broken.

Separately, the -reverse pods log Failed to find Server Action warnings — stale browser tabs POSTing build-scoped action IDs after a redeploy.

Changes

Tenant auth failures (401/403)

  • src/adapters/server/api-error.ts — wraps the content-delivery client so any 401/403 from the Prezly API logs a warning with the request host and newsroom UUID, throttled to once per newsroom per minute, then rethrows. Covers all app() calls, API routes, and the middleware.
  • middleware.ts — when locale resolution hits an auth error, respond 503 + retry-after: 300 before rendering starts. 503 (not 404) so search engines treat the outage as temporary. When the gateway supplies X-Newsroom-Locales, the middleware skips the API and render-path failures still surface as 500 with the branded error page — an RSC render cannot emit a custom status; the log spam was the actionable problem.

Log noise

  • next-logger.config.js — pino err serializer that strips the API response headers from serialized errors while keeping status, payload, stack, and digest. Picked up automatically by the existing NODE_OPTIONS='-r next-logger' in the Dockerfile. Error lines: ~6,500 → ~690 bytes.

Server Actions eliminated

  • app/api/stories/[uuid]/pdf/route.ts — replaces the getStoryPdfUrl Server Action with a stable-URL route handler (same logic, plus UUID validation → 400 on malformed input). The client util now fetches it.
  • htmlToText — dropped 'use server'; html-to-text is pure JS and runs in the browser inside the already-lazy "Copy text" chunk (First Load JS unchanged, story page 265 kB).
  • The build's server-reference manifest is now empty — zero Server Actions remain, so the deploy-skew error class cannot recur.

Verification

  • tsc, biome check, and full next build all clean.
  • Ran the production build locally with next-logger preloaded and a forged X-Prezly-Env carrying an invalid token:
    • middleware path → 503 with retry-after: 300;
    • render path → 500 + Prezly API rejected the access token (401) for newsroom=… host=broken-tenant.example.com logged once;
    • GET /api/stories/<uuid>/pdf{"url":null} gracefully; malformed uuid → 400;
    • zero CSP header content in any log line.

Follow-up (outside this repo)

The root cause is upstream: the routing layer keeps sending traffic (with a stale token) for a tenant the API rejects. Once deployed, the new warn logs identify exactly which newsroom/host it is; the durable fix is to stop routing revoked tenants at the gateway and make token rotation update the env store atomically.

Production pods log a flood of "API Error (401): Unauthorized" entries
when a tenant's access token is revoked or rotated, with no way to tell
which newsroom is affected. Separately, the -reverse pods log "Failed to
find Server Action" warnings after deploys due to build-scoped action IDs.

- Log API auth failures (401/403) once per newsroom per minute with the
  request host and newsroom UUID, so the broken tenant is identifiable
  from the logs.
- Short-circuit with 503 + retry-after in the middleware when the tenant
  token is rejected, instead of letting the render crash with a 500.
- Strip API response headers (multi-KB CSP dump) from serialized error
  logs via a next-logger pino serializer (~6.5KB -> ~700B per line).
- Replace the getStoryPdfUrl Server Action with a route handler
  (/api/stories/[uuid]/pdf) and run htmlToText in the browser, leaving
  zero Server Actions in the build - eliminating deploy-skew
  "Failed to find Server Action" errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@prezly-quality-agent prezly-quality-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR addresses API auth failures and removes Server Actions, but has several security and error handling concerns that need attention.

Comment thread app/api/stories/[uuid]/pdf/route.ts
Comment thread app/api/stories/[uuid]/pdf/route.ts
Comment thread app/api/stories/[uuid]/pdf/route.ts Outdated
Comment thread middleware.ts
Comment thread next-logger.config.js Outdated
Comment thread src/adapters/server/api-error.ts
Comment thread src/adapters/server/api-error.ts
Comment thread src/modules/Story/Share/utils/getStoryPdfUrl.ts
Comment thread src/modules/Story/Share/utils/getStoryPdfUrl.ts Outdated
- PDF route: map upstream failures to proper statuses (404 passthrough,
  502 otherwise) instead of returning 200 with a null URL, log auth
  failures with tenant identification, and validate the upstream
  Location header as an absolute https URL before returning it.
- getStoryPdfUrl: validate the response shape structurally instead of
  a blind type assertion; document the helper as browser-only.
- next-logger serializer: only strip headers from HTTP-response-shaped
  errors (status + headers present).
- Auth log throttle map: bound at 1,000 entries with expired-entry
  pruning and oldest-entry eviction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@prezly-quality-agent prezly-quality-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API auth error handling and Server Actions removal look good. The PDF route implementation is solid with proper validation and error handling.

* newsroom per minute.
*/
export function logApiAuthFailure(status: number, requestHeaders: Headers) {
const host = requestHeaders.get('x-forwarded-host') ?? requestHeaders.get('host') ?? 'unknown';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using request.headers.get('host') as the primary fallback instead of x-forwarded-host, since the latter can be spoofed by clients. The host header is more reliable in most deployment scenarios.

// If every tracked entry is still within the throttle window, drop the
// oldest one (Map iterates in insertion order) to keep the size bounded.
if (lastLoggedAt.size >= MAX_TRACKED_TENANTS) {
const oldest = lastLoggedAt.keys().next().value;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The oldest variable could theoretically be undefined if the Map is empty, but this is protected by the size check above. Consider adding a comment explaining this invariant for clarity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant