Next.js AI — Unbounded Retries Cost $2,400
A 3-hour retry loop burned 2.1M tokens and $2,400 — prevent unbounded AI spending in Next.js 16 with production safeguards and cost circuit breakers..
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- Production AI features need streaming, error boundaries, and cost controls — not just a fetch call to OpenAI
- Route Handlers with toDataStreamResponse enable token-by-token streaming with proper backpressure
- Vercel AI SDK v5 abstracts providers but requires manual handling for rate limits, retries, and cost tracking
- Streaming UI must handle connection drops, timeout reconnection, and partial response recovery
- Production failure: unbounded token generation costs $2,400 in 3 hours when a retry loop hits a verbose model
- Biggest mistake: treating AI endpoints like REST APIs — they have variable latency, variable cost, and non-deterministic output
This article addresses a critical production failure pattern in Next.js applications that integrate large language models (LLMs): unbounded retry loops in serverless AI endpoints. When a route handler or API route calls an LLM provider (OpenAI, Anthropic, etc.) and the request fails due to a transient error (rate limit, timeout, 5xx), naive retry logic without exponential backoff and a hard cap can cascade into thousands of invocations within minutes.
At typical pricing of $0.01–$0.03 per GPT-4o-mini call, a single misconfigured retry loop can burn $2,400 in under an hour. The article explains why this happens specifically in Next.js serverless environments (Vercel, Netlify, AWS Lambda) where cold starts and concurrent invocations amplify the problem, and provides a production-grade architecture to prevent it.
The solution centers on treating Next.js Route Handlers as a dedicated AI gateway layer, not just API endpoints. This means implementing token-by-token streaming with graceful degradation (falling back to cached responses or degraded models when the primary fails), non-HTTP error handling for provider-side failures (e.g., context length exceeded, content moderation flags), and application-layer cost controls like token budgets per user/session and circuit breakers that halt all AI calls after a threshold of consecutive failures.
The article also covers rate limiting at the application layer—not just relying on provider-side limits—using in-memory or Redis-backed sliding window counters to prevent abuse from both external users and internal retry storms.
This is not a theoretical piece; it's a postmortem of real incidents. The target audience is senior engineers building AI features in Next.js who have already shipped a prototype and are now hitting production scaling issues. The alternatives—wrapping calls in a separate microservice or using a managed AI gateway like Portkey or Helicone—are mentioned but the focus is on keeping the stack simple within Next.js itself.
When not to use this approach: if your AI calls are low-volume (<100/day) or you're using a fully managed platform like Vercel AI SDK with built-in retry handling, the overhead of custom circuit breakers and token budgets may not justify the complexity.
Adding AI to a Next.js app feels easy on day one — call an API, get a response, render it. By day thirty you are debugging why a streaming connection dropped mid-response, why your OpenAI bill tripled overnight, and why users see a blank screen for 12 seconds with no feedback. Production AI features are a different engineering discipline than REST APIs. They have variable latency, variable cost, non-deterministic output, and failure modes that look nothing like a 404. This article covers the patterns that make AI features reliable, observable, and cost-controlled in production.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Most AI integration tutorials end at the API call. You get a working demo, a happy path, and a deployment that breaks the first time a user sends a 10,000-token prompt or the provider returns a 429.
Production AI features require five things the tutorials skip: streaming with graceful degradation, structured error handling for non-HTTP failures (timeouts, content filtering, token limits), cost tracking per request, rate limiting at the application layer, and UX patterns that handle 2-second to 30-second response times without confusing users.
This covers the architecture, code patterns, and failure modes for building AI chat, content generation, and agent workflows in Next.js 16. Assume you have a working Next.js app and an OpenAI API key. The patterns apply to any provider — Anthropic, Google, Mistral, or self-hosted models.
Why Unbounded AI Retries in Next.js Cost $2,400
Production-grade AI features in Next.js are server-rendered or server-action-based integrations that handle model inference, streaming, and error recovery with deterministic cost and latency guarantees. The core mechanic is that every AI call—whether to OpenAI, Anthropic, or a local model—must be wrapped in a retry strategy with exponential backoff, a maximum attempt count, and a circuit breaker. Without these, a single transient failure can cascade into thousands of retries, each incurring token costs.
In practice, this means using Next.js server actions or API routes with a retry wrapper that caps attempts at 3, uses jittered backoff (e.g., 1s, 2s, 4s), and tracks a sliding window of failures per model endpoint. The key property is that retries are not free: each call burns tokens, and a burst of 10,000 retries at $0.03 per 1K tokens costs $300—fast. A real system must also distinguish between retryable errors (timeouts, 429s) and non-retryable ones (invalid input, auth failures).
Use this pattern whenever your Next.js app calls an external AI API from server components, server actions, or route handlers. It matters because AI costs are unbounded by default: a misconfigured retry loop in a getServerSideProps or a client-side useEffect can silently burn through your monthly budget in minutes. Production-grade means you treat AI calls like database transactions—with idempotency keys, dead-letter queues, and monitoring.
Architecture: Route Handlers as the AI Gateway
Every AI feature in Next.js 16 starts with a Route Handler. The route handler is the gateway between the client and the AI provider. It handles authentication, input validation, rate limiting, cost tracking, and streaming. The client never talks to the provider directly — your API key stays on the server.
The architecture has three layers. Layer 1: the client sends a request to /api/chat. Layer 2: the route handler validates input, checks rate limits, checks budget, and calls the AI provider. Layer 3: the provider streams tokens back through the route handler to the client via a ReadableStream.
Critical 2026 update: set runtime and timeout explicitly. Vercel Hobby kills functions after 10s (60s max), Pro after 15s (300s max). Edge is capped at 25s for streaming — it is NOT unlimited.
import { openai } from '@ai-sdk/openai'; import { streamText, type CoreMessage } from 'ai'; import { NextRequest } from 'next/server'; import { checkRateLimit } from '@/lib/rate-limit'; import { checkBudget, trackCost, estimateCost } from '@/lib/cost-tracker'; // Vercel 2026 limits: Hobby 10s/60s max, Pro 15s/300s max, Edge 25s export const runtime = 'nodejs'; export const maxDuration = 60; export const dynamic = 'force-dynamic'; export async function POST(req: NextRequest) { const { messages }: { messages: CoreMessage[] } = await req.json(); if (!messages?.length) { return Response.json({ error: 'messages required' }, { status: 400 }); } const userId = req.headers.get('x-user-id') ?? 'anonymous'; // Budget check BEFORE calling provider const budget = await checkBudget(userId); if (!budget.allowed) { return Response.json({ error: budget.reason }, { status: 402 }); } const rateLimit = await checkRateLimit(userId); if (!rateLimit.allowed) { return Response.json( { error: 'Rate limit exceeded', retryAfter: rateLimit.retryAfter }, { status: 429, headers: { 'Retry-After': String(rateLimit.retryAfter) } } ); } // Pre-flight cost estimation const estPromptTokens = Math.ceil(JSON.stringify(messages).length / 4); if (estimateCost('gpt-4o', estPromptTokens, 2048) > 0.05) { return Response.json({ error: 'Request exceeds cost cap' }, { status: 402 }); } const result = streamText({ model: openai('gpt-4o'), messages, maxTokens: 2048, temperature: 0.7, onFinish: async (event) => { await trackCost({ userId, model: 'gpt-4o', promptTokens: event.usage?.promptTokens ?? 0, completionTokens: event.usage?.completionTokens ?? 0, }); }, }); return result.toDataStreamResponse(); }
- Client talks to your route handler, never directly to the provider
- Always set runtime, maxDuration, and dynamic = 'force-dynamic' for AI routes
- Check budget BEFORE streaming — prevents wasted calls
- onFinish fires after stream completes — use for cost tracking and observability
Streaming: Token-by-Token with Graceful Degradation
Streaming is mandatory. Non-streaming waits 40s for 2,000 tokens. Users abandon after 5s.
'use client'; import { useChat } from '@ai-sdk/react'; import { useState } from 'react'; export function ChatInterface() { const [status, setStatus] = useState<'connected'|'reconnecting'|'disconnected'>('connected'); const { messages, input, handleInputChange, handleSubmit, isLoading, error, reload, stop } = useChat({ api: '/api/chat', onError: (err) => { if (err.message.includes('timeout')) { setStatus('reconnecting'); setTimeout(() => reload(), 2000); } else { setStatus('disconnected'); } }, onFinish: () => setStatus('connected'), }); return ( <div className="flex flex-col h-full"> {status !== 'connected' && ( <div className="bg-yellow-500/10 px-4 py-2 text-sm">{status}...</div> )} <div className="flex-1 overflow-y-auto p-4"> {messages.map(m => <div key={m.id}>{m.content}</div>)} {isLoading && <div className="animate-pulse">Thinking...</div>} </div> {error && <button onClick={() => reload()}>Retry</button>} <form onSubmit={handleSubmit}> <input value={input} onChange={handleInputChange} disabled={isLoading} /> <button type="submit">Send</button> {isLoading && <button type="button" onClick={stop}>Stop</button>} </form> </div> ); }
Error Handling: Non-HTTP Failures
AI errors need classification: retryable (429, 500), user-actionable (content_policy_violation), permanent (401). Parse Retry-After header.
export type ErrorCategory = 'retryable' | 'user_actionable' | 'permanent'; export function classifyAIError(error: any) { const status = error.statusCode ?? error.cause?.statusCode ?? 500; const code = error.cause?.error?.code ?? ''; const retryAfter = Number(error.responseHeaders?.['retry-after']) || 60; if (status === 429) return { category: 'retryable', retryAfter, userMessage: 'Busy, retrying...' }; if (code === 'content_policy_violation') return { category: 'user_actionable', userMessage: 'Blocked by safety filter' }; if (status >= 500) return { category: 'retryable', retryAfter: 5, userMessage: 'Server error, retrying' }; return { category: 'permanent', userMessage: 'Configuration error' }; }
Cost Control: Token Budgets and Circuit Breakers
Use Redis for cost tracking — in-memory fails on serverless.
import { Redis } from '@upstash/redis'; const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL!, token: process.env.UPSTASH_REDIS_REST_TOKEN! }); const PRICING = { 'gpt-4o': { p: 0.0025/1000, c: 0.01/1000 }, 'gpt-4o-mini': { p: 0.00015/1000, c: 0.0006/1000 } }; const USER_BUDGET = 5; const GLOBAL_BUDGET = 50; const today = () => new Date().toISOString().split('T')[0]; export async function trackCost({userId, model, promptTokens, completionTokens}: any) { const price = PRICING[model as keyof typeof PRICING] ?? {p:0.01/1000,c:0.03/1000}; const cost = promptTokens*price.p + completionTokens*price.c; await redis.incrbyfloat(`ai:cost:user:${userId}:${today()}`, cost); await redis.incrbyfloat(`ai:cost:global:${today()}`, cost); } export async function checkBudget(userId: string) { const user = Number(await redis.get(`ai:cost:user:${userId}:${today()}`) || 0); const global = Number(await redis.get(`ai:cost:global:${today()}`) || 0); if (global >= GLOBAL_BUDGET) return { allowed: false, reason: `Daily budget $${GLOBAL_BUDGET} exceeded` }; if (user >= USER_BUDGET) return { allowed: false, reason: `User budget $${USER_BUDGET} reached` }; return { allowed: true }; } export const estimateCost = (m:string, p:number, c:number) => { const price = PRICING[m as keyof typeof PRICING] ?? {p:0.01/1000,c:0.03/1000}; return p*price.p + c*price.c; }
Rate Limiting: Application-Layer Protection
Provider limits protect provider, not you. Use Upstash.
import { Ratelimit } from '@upstash/ratelimit'; import { Redis } from '@upstash/redis'; const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL!, token: process.env.UPSTASH_REDIS_REST_TOKEN! }); export const chatLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(10, '1 m') }); export const checkRateLimit = async (id:string) => { const r = await chatLimiter.limit(id); return { allowed: r.success, retryAfter: Math.ceil((r.reset - Date.now())/1000) }; };
Agent Workflows: Tool Calls with maxSteps
Treat tool args as untrusted. Add timeouts.
import { streamText, tool } from 'ai'; import { openai } from '@ai-sdk/openai'; import { z } from 'zod'; export const maxDuration = 60; export async function POST(req: Request) { const { messages } = await req.json(); const result = streamText({ model: openai('gpt-4o'), messages, maxSteps: 5, tools: { search: tool({ description: 'Search KB', parameters: z.object({ query: z.string().max(500) }), execute: async ({ query }) => { const timeout = new Promise((_,r)=>setTimeout(()=>r('timeout'),5000)); try { return await Promise.race([searchDB(query), timeout]); } catch { return { error: 'timeout' }; } } }) } }); return result.toDataStreamResponse(); } async function searchDB(q:string){ return [] }
Provider Abstraction: Swap Models Without Changing Client
Implement automatic fallback parsing Retry-After.
import { openai } from '@ai-sdk/openai'; import { anthropic } from '@ai-sdk/anthropic'; import { streamText } from 'ai'; export async function streamWithFallback(messages:any, complexity:'simple'|'standard'|'complex'='standard'){ const config = { simple: { primary: openai('gpt-4o-mini'), fallback: anthropic('claude-3-5-haiku-20241022'), max:1024 }, standard: { primary: openai('gpt-4o'), fallback: anthropic('claude-3-5-sonnet-20241022'), max:2048 }, complex: { primary: anthropic('claude-3-5-sonnet-20241022'), fallback: openai('gpt-4o'), max:4096 } }[complexity]; try { return streamText({ model: config.primary, messages, maxTokens: config.max }); } catch(e:any){ const status = e.statusCode ?? 500; const retryAfter = Number(e.responseHeaders?.['retry-after']) || 60; if(status===429 || status>=500){ await new Promise(r=>setTimeout(r, Math.min(retryAfter,5)*1000)); return streamText({ model: config.fallback, messages, maxTokens: config.max }); } throw e; } }
Testing AI Features
Test plumbing, not poetry. Mock providers.
import { describe, it, expect, vi } from 'vitest'; vi.mock('ai', () => ({ streamText: vi.fn(() => ({ toDataStreamResponse: () => new Response('ok') })) })); import { POST } from '@/app/api/chat/route'; describe('chat', () => { it('rejects empty', async () => { const r = await POST(new Request('http://test', {method:'POST', body: JSON.stringify({messages:[]})}) as any); expect(r.status).toBe(400); }); });
Auth: Why Your JWT Will Burn in Production
Every tutorial shows you how to slap a JWT on a cookie and call it auth. Production reality is different — token refresh races, CSRF on API routes, and session leakage through server components. The Next.js App Router makes auth deceptively complex because Server Components can't access cookies the same way client code does.
You need a middleware-based session check that validates tokens before they ever hit a route handler. But middleware runs on the Edge Runtime — no Node crypto, no direct DB access. Your token validation must be deterministic without external calls or you'll spike latency on every page navigation.
Store refresh tokens in httpOnly cookies with a short-lived access token in memory. Use the jose library over jsonwebtoken because it works in Edge middleware without polyfills. Protect API routes by wrapping your route handlers with a withAuth higher-order function that extracts and verifies the bearer token before any business logic runs.
The trap? Server Components fetching data on your behalf. If your data layer calls an API route expecting auth headers, the component has no way to inject them. Either pass auth context down explicitly or use a dedicated service that reads the session cookie directly.
// io.thecodeforge — javascript tutorial import { jwtVerify } from 'jose'; import { NextResponse } from 'next/server'; const secret = new TextEncoder().encode(process.env.JWT_SECRET); export function withAuth(handler) { return async (request, context) => { const token = request.headers.get('authorization')?.split('Bearer ')[1]; if (!token) { return NextResponse.json({ error: 'Missing token' }, { status: 401 }); } try { const { payload } = await jwtVerify(token, secret); request.user = payload; return handler(request, context); } catch (err) { return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 }); } }; } // Usage: // export const GET = withAuth(async (request) => { ... });
layout.tsx or page.tsx that runs on the server. If the component re-renders due to a parent change, your token check repeats — potentially at an unpredictable rate. Middleware is the only safe place for auth enforcement.Rendering: The Cost of Forgetting Cache Tags
You think you understand incremental static regeneration. You read the docs about revalidate and fetchCache. Then your e-commerce site shows yesterday's prices for three hours because you didn't invalidate the product page cache when inventory changed. That's not static generation — that's a static lie.
Next.js gives you three rendering modes: static, dynamic, and ISR. The trap is mixing them without understanding cache propagation. A static page that fetches data from a dynamic API route? The API response gets cached at the CDN level, and your revalidate on the page won't touch it. You end up with stale data served fast — the worst of both worlds.
Use unstable_noStore inside data fetching functions that must be fresh on every request. Tag your fetch calls with next: { tags: ['product-123'] } and call revalidateTag('product-123') from your webhook handler when inventory updates. This is the only reliable pattern for cache invalidation in the App Router.
For streaming SSR, remember that loading.tsx fires before your data resolves. If you hide the loading spinner too early or too late, users see flash of empty content. Set a minimum loading duration of 200ms to prevent flicker on fast responses.
// io.thecodeforge — javascript tutorial import { revalidateTag } from 'next/cache'; export async function GET(request, { params }) { const productId = params.id; // Fetch with cache tag for targeted invalidation const res = await fetch(`https://api.warehouse.com/products/${productId}`, { next: { tags: [`product-${productId}`] } }); const product = await res.json(); return Response.json(product); } // Call from webhook when inventory changes // POST /api/webhooks/inventory // io.thecodeforge — javascript tutorial export async function POST(request) { const { productId } = await request.json(); revalidateTag(`product-${productId}`); return Response.json({ revalidated: true }); }
Tech Stack: Why You Need a Router, Not a Framework
Every AI feature you ship runs through a chain: client → Next.js route handler → provider SDK → model. That chain is only as strong as the weakest library. Pick wrong and you’re debugging a socket leak at 3 AM.
The non-negotiable stack starts with Vercel AI SDK for streaming and tool calling — it standardizes the pipe. Add Zod for runtime input validation (no, TypeScript alone won't catch a malformed JSON payload at 2,000 RPM). For persistent state, use Redis-backed queues, not in-memory maps. Your serverless function will cold start and lose five minutes of retries. Finally, wrap everything in OpenTelemetry traces. If you cannot see why a $200 request timed out, you cannot fix it.
The temptation is to import every shiny AI library. Resist. Every extra dependency is an incident waiting to happen. You want three things: a router that handles auth and rate limiting, a streaming SDK that handles backpressure, and a validation layer that kills bad input early. That’s it. Anything else is technical debt with marketing copy.
// io.thecodeforge — javascript tutorial import { z } from 'zod'; import { Ratelimit } from '@upstash/ratelimit'; import { Redis } from '@upstash/redis'; const promptSchema = z.object({ model: z.enum(['gpt-4', 'claude-3']), messages: z.array(z.object({ role: z.string(), content: z.string() })).min(1), }); const ratelimit = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(10, '10 s'), }); export async function POST(req) { const { success } = await ratelimit.limit(req.headers.get('x-user-id')); if (!success) return new Response('Slow down.', { status: 429 }); const parsed = promptSchema.safeParse(await req.json()); if (!parsed.success) return new Response('Bad input.', { status: 400 }); return new Response('All good.', { status: 200 }); }
fetch() to call your own route handlers from the client. You'll double-hop through the network layer, lose streaming benefits, and pay for two cold starts. Import the logic directly or use a server action.Documentation: Your AI Feature’s First Line of Defense
Nobody reads docs. Until they hit a 503 at 2 AM and need to know why your streaming endpoint drops tokens after 30 seconds. Documentation for AI features is not a README — it’s runbooks for the on-call engineer who hates your code.
Start with the failure modes. Document every error code your route handler can return, and what the client should do. Show the exact retry policy: exponential backoff with jitter capped at 30 seconds. Copy-paste the curl commands for each model provider — your future self will thank you when Claude deprecates an API version. Include the cost matrix: token budgets per user tier, per model, per endpoint. If a junior dev deploys a prompt that costs $0.50 per call, your documentation should have screamed at them first.
Finally, write the “why” for every architectural decision. Why Redis over Postgres for rate limiting? Why Zod over Yup? Because next year someone will refactor and break the streaming pipeline. Your doc is the only thing standing between that refactor and a production outage. Treat it like code: review it, version it, and make it executable.
// io.thecodeforge — javascript tutorial /** * POST /api/ai/chat * Streams tokens from specified model. * * Errors: * 400 — Invalid prompt schema (see Zod schema below) * 429 — Rate limit exceeded (10 req/10s per user) * 502 — Provider returned non-200 (circuit breaker open) * 504 — Stream timed out after 30s * * Retry policy: * - 429: wait retry-after header, max 3 retries * - 502: exponential backoff 1s/2s/4s, max 3 retries * - 504: no retry — reduce prompt length * * Cost: * - gpt-4: $0.03/1K input tokens * - claude-3: $0.015/1K input tokens * - Token budget: 4K per user per hour */ export const runtime = 'edge';
State Management: Why Your AI Feature Will Reset Mid-Stream
Most AI features in Next.js fail because developers treat state as an afterthought. When a route handler streams tokens, a user navigates away, or a serverless function cold-starts, the entire conversation context vanishes. This isn't a UI bug—it's a data loss event. You must externalize state outside React's useState or useReducer. Use Redis or Vercel KV to persist conversation threads, tool call results, and streaming checkpoints. Every AI request must carry a session ID tied to durable storage. Without this, retries restart from zero, costing tokens and breaking user trust. Implement a state manager that writes on every meaningful event: token receipt, tool execution, error recovery. The rule: if your app freezes and restarts, the user should never notice.
// io.thecodeforge — javascript tutorial import { createClient } from 'redis'; const client = createClient({ url: process.env.REDIS_URL }); export async function saveSession(sessionId, state) { await client.set(`session:${sessionId}`, JSON.stringify(state), { EX: 600 }); } export async function loadSession(sessionId) { const raw = await client.get(`session:${sessionId}`); return raw ? JSON.parse(raw) : null; } export async function appendToken(sessionId, token) { const state = await loadSession(sessionId) || { tokens: [], toolCalls: [] }; state.tokens.push(token); await saveSession(sessionId, state); }
Observability: Why Your AI Feature Is a Black Box of Failures
Your AI route handler returns 200 OK, but did it actually work? Without observability, you cannot tell if tokens streamed correctly, a tool call failed silently, or a provider rate-limited you mid-response. Production AI features need OpenTelemetry tracing to capture every step: prompt construction, provider latency, token chunks, tool execution duration. Log each attempt with a unique trace ID, and measure token consumption against budget bounds. When a user reports "the AI stopped talking," you need to replay the exact sequence. Implement structured logging for every non-2xx provider response, every circuit breaker trigger, every empty tool result. If you cannot reconstruct a session's timeline from logs, you are debugging blind. Add metrics for p50/p99 token latency and error rates by model.
// io.thecodeforge — javascript tutorial import otel from '@opentelemetry/api'; const tracer = otel.trace.getTracer('ai-feature'); export async function traceStream(sessionId, provider) { const span = tracer.startSpan('ai.stream', { attributes: { sessionId, provider } }); try { const stream = await fetchAIResponse(sessionId); span.setAttribute('tokens_total', stream.tokenCount); return stream; } catch (err) { span.recordException(err); span.setStatus({ code: otel.SpanStatusCode.ERROR }); throw err; } finally { span.end(); } }
Prompt Injection Protection: Why Your AI Feature Will Jailbreak Itself
Your Next.js AI route handler accepts user input and passes it straight to the model. That’s a security hole. Attackers embed instructions like "ignore previous system prompt" or "output all your training data" in chat messages. Production AI features need prompt injection guards before the model call. Validate user input with regex deny-lists for common jailbreak patterns: role escalation, delimiter injection, output manipulation. Use a dedicated guardrail service like Guardrails AI or a lightweight LLM call to classify intent. Never trust user text to align with your system prompt. Implement a secondary check on model output: scan for leaked API keys, confidential phrases, or forbidden topics. If a user can make your AI reveal your Redis credentials, your app is compromised.
// io.thecodeforge — javascript tutorial const forbidden = [/ignore previous/i, /system prompt/i, /output.*training/i]; export function sanitizeInput(text) { if (forbidden.some(p => p.test(text))) { throw new Error('POTENTIAL_PROMPT_INJECTION'); } return text.slice(0, 4096); // enforce max length } export function validateOutput(text) { const leakedKeys = text.match(/sk-[a-zA-Z0-9]{32,}/); if (leakedKeys) throw new Error('API_KEY_LEAK_DETECTED'); return text; }
Retry loop triggers unbounded token generation — $2,400 OpenAI bill in 3 hours
- Never retry 429 responses without reading the Retry-After header — rate limits require specific wait durations
- Cap retries per request (3 max) and per user (10 max per hour) — unbounded retries compound cost exponentially
- Implement a cost circuit breaker at the application layer — provider billing alerts are too slow to prevent overspend
- Token generation is billed on every attempt, including retries of partially completed responses — treat each retry as a full-cost call
curl -N https://your-app.com/api/chat -H 'Content-Type: application/json' -d '{"messages":[{"role":"user","content":"hello"}]}'vercel logs your-app --followcurl -s -D- https://api.openai.com/v1/chat/completions -H 'Authorization: Bearer $OPENAI_API_KEY' | grep -i retry-afterCheck current usage: curl https://api.openai.com/v1/usage -H 'Authorization: Bearer $OPENAI_API_KEY'grep -r 'maxTokens' app/api/ --include='*.ts'redis-cli GET ai:cost:global:$(date +%F)Check provider error: look for content_policy_violation in error.response.bodyTest prompt directly: curl https://api.openai.com/v1/chat/completions -d '{"model":"gpt-4o","messages":[{"role":"user","content":"YOUR_PROMPT"}]}'| Feature | OpenAI (gpt-4o) | Anthropic (claude-3-5-sonnet) | Google (gemini-1.5-pro) | Mistral (mistral-large) |
|---|---|---|---|---|
| Streaming | Yes | Yes | Yes | Yes |
| Tool Calls | Yes | Yes | Yes | Yes |
| Context | 128K | 200K | 1M | 128K |
| Cost /1M in/out | $2.50 / $10 | $3 / $15 | $1.25 / $5 | $0.25 / $0.75 |
| Edge Runtime | Yes (25s) | Yes (25s) | Yes (25s) | Yes (25s) |
| File | Command / Code | Purpose |
|---|---|---|
| app | export const runtime = 'nodejs'; | Architecture |
| components | 'use client'; | Streaming |
| lib | export type ErrorCategory = 'retryable' | 'user_actionable' | 'permanent'; | Error Handling |
| lib | const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL!, token: proce... | Cost Control |
| lib | const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL!, token: proce... | Rate Limiting |
| app | export const maxDuration = 60; | Agent Workflows |
| lib | export async function streamWithFallback(messages:any, complexity:'simple'|'stan... | Provider Abstraction |
| __tests__ | vi.mock('ai', () => ({ streamText: vi.fn(() => ({ toDataStreamResponse: () => ne... | Testing AI Features |
| withAuth.js | const secret = new TextEncoder().encode(process.env.JWT_SECRET); | Auth |
| CacheInvalidation.js | export async function GET(request, { params }) { | Rendering |
| StackCheck.js | const promptSchema = z.object({ | Tech Stack |
| ApiDocs.js | /** | Documentation |
| StateManager.js | const client = createClient({ url: process.env.REDIS_URL }); | State Management |
| Observability.js | const tracer = otel.trace.getTracer('ai-feature'); | Observability |
| InjectionGuard.js | const forbidden = [/ignore previous/i, /system prompt/i, /output.*training/i]; | Prompt Injection Protection |
Key takeaways
Common mistakes to avoid
6 patternsCalling provider from client
No maxTokens
Using generateText
Retrying 429 without Retry-After
No stream interruption handling
Untrusted tool args
Interview Questions on This Topic
How handle 429s?
Implement cost tracking?
Frequently Asked Questions
Yes, use pages/api. Patterns identical. App Router preferred for Edge/Node config.
Hobby 10s (60s max), Pro 15s (300s max), Edge 25s. Set maxDuration. Use background jobs for >300s.
Mock provider, test validation and plumbing, not text.
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
That's Next.js. Mark it forged?
6 min read · try the examples if you haven't