In the winter of 2026, the Sentry inbox for one of my solo SaaS apps was greeting me every morning with thirty to eighty invalid_request_error notifications. Each one had a slightly different cause — messages that broke role alternation, a tools[].input_schema missing its type field, a max_tokens value above the model ceiling. The only thing they shared was that every single failure had traveled all the way to Anthropic's servers before being rejected.
There is a particular kind of exhaustion that comes from the repetition of preventable errors. The same five mistakes, dressed in slightly different payloads, every morning. The same retry loops, the same false-positive paging, the same sense that the on-call rotation is being eroded by problems your own code is creating.
After a week of triaging the same shapes of mistake, I finally accepted what should have been obvious from the start: the request never had to leave my own infrastructure. If I could mirror the validation Anthropic was doing on the other side of the wire, the failure would simply not happen. That is the spirit of "preflight" — an old idea from HTTP, ported deliberately to the Claude API. After one month of running this design in production, the invalid_request_error count dropped from 412 to 0, and overloaded_error notifications fell to roughly half of what they had been. If you are tired of the same error patterns drowning out the incidents that actually matter, this is the architecture I wish I had written six months earlier.
The Mindset: Don't Call What You Don't Have to Call
Retries and circuit breakers are about catching broken calls gracefully. Preflight is one layer upstream — it is about making sure the broken call never happens. That single shift in framing buys you three savings at once.
- API cost. A 400 itself is free, but the retry loop sitting on top of most production code keeps hammering until something returns 200. Preflight rejects the request before the retry loop ever starts, so the wasted round-trips disappear.
- Compute cost. Cloudflare Workers and Lambda bill for the wall-clock time of outbound calls. A request rejected in preflight finishes in single-digit milliseconds. A retried 400 might burn a hundred times that.
- Cognitive cost. A Sentry inbox full of self-inflicted 400s teaches you to ignore Sentry. The 529 from a real Anthropic incident gets buried in the noise. Preflight makes the inbox trustworthy again.
The mental model I found useful is to treat preflight as a "miniature Anthropic server" inside your own stack. You do not need to perfectly reproduce every check Anthropic runs. You only need to cover the top five error patterns showing up in your logs — and that is usually enough to take the noise floor down by an order of magnitude.
Layer 1 — Schema Validation for Messages and Tools
The most frequent shape of mistake in my logs was a malformed messages array. Roles alternating wrong, empty content blocks, tool_use_id references that pointed nowhere — the official SDK guards some of this, but TypeScript types vanish at runtime. A runtime validator at the API boundary is non-negotiable.
I use Zod, because the same definition gives me a TypeScript type and an executable check. The deeper patterns for typing Claude tool calls with Zod are explored in Type-safe Claude API tool calling with Zod. Below is the minimal schema I keep in production today.
// preflight/schema.ts
import { z } from "zod";
const TextBlock = z.object({
type: z.literal("text"),
text: z.string().min(1, "text content cannot be empty"),
});
const ToolUseBlock = z.object({
type: z.literal("tool_use"),
id: z.string().regex(/^toolu_/),
name: z.string().min(1),
input: z.record(z.unknown()),
});
const ToolResultBlock = z.object({
type: z.literal("tool_result"),
tool_use_id: z.string().regex(/^toolu_/),
content: z.union([z.string(), z.array(TextBlock)]),
is_error: z.boolean().optional(),
});
const ContentBlock = z.discriminatedUnion("type", [
TextBlock,
ToolUseBlock,
ToolResultBlock,
]);
const Message = z.object({
role: z.enum(["user", "assistant"]),
content: z.union([z.string().min(1), z.array(ContentBlock).min(1)]),
});
export const RequestSchema = z.object({
model: z.string(),
max_tokens: z.number().int().positive(),
messages: z.array(Message).min(1).refine(
(msgs) => alternates(msgs),
"messages must alternate between user and assistant"
),
system: z.union([z.string(), z.array(TextBlock)]).optional(),
tools: z.array(z.object({
name: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/),
description: z.string().min(1),
input_schema: z.object({
type: z.literal("object"),
properties: z.record(z.unknown()),
required: z.array(z.string()).optional(),
}),
})).optional(),
});
function alternates(msgs: { role: string }[]): boolean {
for (let i = 1; i < msgs.length; i++) {
if (msgs[i].role === msgs[i - 1].role) return false;
}
return true;
}
// Usage: const parsed = RequestSchema.safeParse(req); if (!parsed.success) { ... }The trick is to drive the schema from real Anthropic error messages, not from the documentation alone. The messages must alternate refinement, for example, is a direct echo of an error I had seen in production a dozen times. The toolu_ prefix on tool_use_id is not formally documented but is a stable empirical pattern that catches a real class of bugs — copy-pasted IDs from previous turns.
The five most common 400 causes I caught at this layer
- An empty
messagesarray (the front-end submitting a chat with no user input) - Two consecutive messages with the same role (a summarizer that merged assistant turns and forgot to insert a user turn)
- A
tool_resultwhosetool_use_iddid not match any precedingtool_use.id - A
tools[].namecontaining whitespace or non-ASCII characters max_tokensset to zero or a negative number from a missing environment variable
Once Layer 1 was in place, all five of those failure modes stopped reaching Anthropic. More importantly, the implementation cost was small — under two hundred lines of TypeScript, half of which was the schema definition itself. The runtime overhead measured at the p99 was under three milliseconds per request, which is invisible against the typical Claude API latency. If you only ever ship one of the five layers, ship Layer 1.
Layer 2 — Token Budget Guard, Stopping 413 and 529 Before They Happen
Even with a 200K or 1M context window, the sum of input tokens plus max_tokens cannot exceed the ceiling. Worse, my own measurements suggest that very large inputs increase the probability of overloaded_error (529), so being conservative about input size pays a reliability dividend even before you hit the hard cap. Anthropic exposes a count_tokens endpoint, but calling it on every request adds latency. My compromise is "estimate in preflight, sample-audit against count_tokens periodically."
// preflight/token-budget.ts
import { get_encoding } from "@dqbd/tiktoken";
// Anthropic ships its own tokenizer, but cl100k-base estimates land
// within roughly five percent for our traffic mix. Audit periodically.
const enc = get_encoding("cl100k_base");
interface TokenBudgetResult {
estimated: number;
contextLimit: number;
maxTokens: number;
ok: boolean;
reason?: string;
}
const MODEL_LIMITS: Record<string, number> = {
"claude-opus-4-6": 200_000,
"claude-sonnet-4-6": 200_000,
"claude-haiku-4-5-20251001": 200_000,
};
export function checkTokenBudget(req: {
model: string;
max_tokens: number;
messages: Array<{ role: string; content: unknown }>;
system?: string;
tools?: Array<{ description: string; input_schema: unknown }>;
}): TokenBudgetResult {
const limit = MODEL_LIMITS[req.model] ?? 200_000;
const serialized =
(req.system ?? "") +
JSON.stringify(req.messages) +
JSON.stringify(req.tools ?? []);
const inputTokens = enc.encode(serialized).length;
const estimated = inputTokens + req.max_tokens;
const headroom = Math.floor(limit * 0.95); // five percent safety margin
if (estimated > headroom) {
return {
estimated,
contextLimit: limit,
maxTokens: req.max_tokens,
ok: false,
reason: `estimated ${estimated} tokens exceeds 95% of context (${headroom})`,
};
}
return { estimated, contextLimit: limit, maxTokens: req.max_tokens, ok: true };
}
// Expected output shape: { estimated: 12345, contextLimit: 200000, ok: true }The deceptive thing about token-related errors is that they look like model errors. A 413 traveling back through your error pipeline arrives wearing the same skin as a 529, and on a busy day the temptation to lump them together is strong. Layer 2 is what lets you separate the two. Once your dashboard can show "rejections from preflight" alongside "rejections from Anthropic," the operational picture becomes legible for the first time.
The five-percent headroom exists because cl100k-based estimates skew low on text-heavy Japanese inputs in my workload. In our environment, every request whose estimate landed above 190,000 on a 200K model produced either a 413 or a 529 — the danger zone is real, and a static guard is cheaper than a tokenizer round-trip.
Layer 3 — Model Capability Checks
Have you ever swapped claude-opus-4-6 for claude-haiku-4-5-20251001 in a production hot-fix and watched all your vision-enabled requests start failing within minutes? The capability matrix differs across models in ways that runtime code rarely notices. The safest pattern I have found is to keep capabilities as a literal constant and match against them at request-construction time.
// preflight/capability.ts
type Feature = "vision" | "tool_use" | "extended_thinking" | "1m_context";
const CAPABILITIES: Record<string, Feature[]> = {
"claude-opus-4-6": ["vision", "tool_use", "extended_thinking"],
"claude-sonnet-4-6": ["vision", "tool_use", "extended_thinking"],
"claude-haiku-4-5-20251001": ["tool_use"],
};
export function checkCapabilities(req: {
model: string;
hasImage: boolean;
usesTools: boolean;
thinking?: { type: string };
}): { ok: boolean; missing: Feature[] } {
const supported = new Set(CAPABILITIES[req.model] ?? []);
const required: Feature[] = [];
if (req.hasImage) required.push("vision");
if (req.usesTools) required.push("tool_use");
if (req.thinking?.type === "enabled") required.push("extended_thinking");
const missing = required.filter((f) => !supported.has(f));
return { ok: missing.length === 0, missing };
}
// Expected output shape: { ok: false, missing: ["vision"] }There is one detail in this layer that I underestimated initially: the value of failing closed when the model is not present in the matrix. The default CAPABILITIES[req.model] ?? [] returns an empty set, which means an unknown model fails every capability check. That is the right default. A typo in a model identifier should produce a preflight rejection in seconds, not a runtime surprise after the request has been billed.
The maintenance cost is low. Anthropic updates the matrix maybe once every six months, and a single shared packages/claude-capabilities module across my four-site monorepo makes the update mechanical. The payoff is that swapping a model is no longer a deployment that fails silently in front of users.
Layer 4 — Content Policy and Implicit Leak Detection
This layer is not just about Anthropic's policy. Some of the worst incidents in my own logs were self-inflicted: a system prompt accidentally appearing inside a user message, or PII leaking into an outbound payload because a sanitizer never ran. A small set of hard-coded markers and patterns catches the lion's share of these mistakes.
// preflight/content-policy.ts
const SYSTEM_LEAK_MARKERS = [
/You are a helpful assistant/i,
/<\|im_start\|>/,
/BEGIN INTERNAL PROMPT/i,
];
const PII_PATTERNS = [
/\b\d{3}-\d{4}-\d{4}\b/, // Japanese phone numbers
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i,
/\b\d{4}-\d{4}-\d{4}-\d{4}\b/, // credit card-like
];
export function checkContentPolicy(req: {
messages: Array<{ role: string; content: string | unknown[] }>;
}): { ok: boolean; violations: string[] } {
const v: string[] = [];
for (const msg of req.messages) {
const text =
typeof msg.content === "string"
? msg.content
: msg.content.map((b: any) => b.text ?? "").join("\n");
for (const re of SYSTEM_LEAK_MARKERS) {
if (re.test(text)) v.push(`possible system prompt leak: ${re}`);
}
for (const re of PII_PATTERNS) {
if (re.test(text)) v.push(`possible PII: ${re}`);
}
}
return { ok: v.length === 0, violations: v };
}My team's policy is to flag rather than reject. For the broader topic of layered defense and more sophisticated injection patterns, see Prompt injection defense patterns for Claude API. A PII match returns a violation list that the calling code can use to trigger a redaction step or a confirmation UI, rather than refusing the user's request outright. The goal is to give engineers an early signal without breaking the experience for users who included an email address on purpose.
Layer 5 — Spend Cap and Rate Control
The last gate is money. For automatic daily shut-off when budgets overflow, the spend-cap layer pairs naturally with the budget circuit breaker design for Claude API. Anthropic enforces an organization-wide spend limit, but per-user and per-feature budgets are your responsibility. I store monthly usage in Cloudflare KV, keyed by user and month, and consult it just before sending any request.
// preflight/budget-guard.ts
interface SpendCap {
monthlyJpy: number;
perRequestJpy: number;
}
export async function checkBudget(
kv: KVNamespace,
userId: string,
cap: SpendCap,
estimatedJpy: number
): Promise<{ ok: boolean; reason?: string }> {
const monthKey = new Date().toISOString().slice(0, 7);
const used = parseFloat(
(await kv.get(`spend:${userId}:${monthKey}`)) ?? "0"
);
if (used + estimatedJpy > cap.monthlyJpy) {
return {
ok: false,
reason: `monthly cap exceeded: ${used + estimatedJpy} > ${cap.monthlyJpy}`,
};
}
if (estimatedJpy > cap.perRequestJpy) {
return {
ok: false,
reason: `per-request cap exceeded: ${estimatedJpy} > ${cap.perRequestJpy}`,
};
}
return { ok: true };
}
// Expected output shape: { ok: false, reason: "monthly cap exceeded: 6200 > 5000" }The estimatedJpy value comes from Layer 2's token estimate multiplied by the model's per-token price. The estimate does not need to be perfect — it needs to be a believable upper bound. Decide a per-request ceiling like "no single user request will ever cost more than ten dollars" and you have a hard wall against runaway abuse, even if a malicious payload sneaks through every other layer.
Composing the Five Layers Into a Single Preflight Function
Splitting the layers into separate modules keeps each one easy to test, but at the call site you want one function and one return type. A discriminated union makes the consumer's life pleasant — a single if (result.kind === "deny") branch is enough.
// preflight/index.ts
import { RequestSchema } from "./schema";
import { checkTokenBudget } from "./token-budget";
import { checkCapabilities } from "./capability";
import { checkContentPolicy } from "./content-policy";
import { checkBudget } from "./budget-guard";
export type PreflightResult =
| { kind: "allow"; estimated: { tokens: number; jpy: number } }
| { kind: "deny"; layer: 1 | 2 | 3 | 4 | 5; reason: string };
export async function preflight(
req: unknown,
ctx: { kv: KVNamespace; userId: string; cap: SpendCap }
): Promise<PreflightResult> {
const parsed = RequestSchema.safeParse(req);
if (!parsed.success) {
return { kind: "deny", layer: 1, reason: parsed.error.message };
}
const r = parsed.data;
const tb = checkTokenBudget(r);
if (!tb.ok) return { kind: "deny", layer: 2, reason: tb.reason! };
const cap = checkCapabilities({
model: r.model,
hasImage: detectImage(r.messages),
usesTools: !!r.tools?.length,
});
if (!cap.ok) {
return { kind: "deny", layer: 3, reason: `missing: ${cap.missing}` };
}
const cp = checkContentPolicy(r);
if (!cp.ok) {
return { kind: "deny", layer: 4, reason: cp.violations.join("; ") };
}
const estimatedJpy = estimateJpy(r.model, tb.estimated);
const bg = await checkBudget(ctx.kv, ctx.userId, ctx.cap, estimatedJpy);
if (!bg.ok) return { kind: "deny", layer: 5, reason: bg.reason! };
return {
kind: "allow",
estimated: { tokens: tb.estimated, jpy: estimatedJpy },
};
}I tag every deny with its layer for observability. In Sentry, denials carry a layer-2 style label; in Datadog, the rates per layer go on a single time-series dashboard. A spike in Layer 3 denials usually means a bad model migration. A spike in Layer 5 means you priced something wrong. Each layer becomes its own signal — preflight is not just a guard, it is a measurement instrument.
What the First Month of Production Looked Like
Below are the numbers I tracked over comparable thirty-day windows before and after rollout. The traffic mix was steady; only the preflight layer changed. These figures come from a single solo-developer environment, so calibrate them against your own traffic, but the directional change is what matters.
invalid_request_errorevents: 412 → 0 (Layers 1 through 3 caught all of them)overloaded_errornotifications: 89 → 41 (Layer 2 reduced average input size enough to take pressure off the upstream)- Aggregate Cloudflare Workers wall time spent on failing calls: about 18 minutes → about 30 seconds
- Monthly Sentry Errors quota consumption: averaging 60% → averaging 12%
- Production incidents in the first week of new feature releases: 2 → 0 (Layer 3 caught capability mismatches in staging)
One number that did not move was the Anthropic API spend on successful calls. That is the right outcome — preflight should not change the cost of legitimate traffic. What it changes is the cost of mistakes, which is the cost most teams are paying without realizing it.
The two highest-leverage layers were 1 and 3. Layer 3 prevents real production incidents the day it ships. Layer 1 quietly removes long-accumulated noise so the alerts you are still receiving are worth reading.
The most surprising effect was on team conversation, not on metrics. Once preflight denials carried layer numbers, the daily standup became more focused. Instead of generic "we got some Claude errors yesterday" updates, the team started saying "Layer 5 spiked between 3 and 4 PM, looks like the new pricing page bug." The shared vocabulary turned what used to be a vague status report into a specific, actionable conversation. That cultural side effect was not on the original requirements list, but it has done more for the on-call experience than any individual technical change.
Layered Denials As a Diagnostic Signal
The promise of the five-layer design is not just blocked failures — it is the dashboard that emerges once each layer is tagging its own denials. After two weeks of running with layer-N labels in our error pipeline, three operational habits became natural that had been impossible before.
The first habit is reading the daily denial breakdown like a weather report. A normal day shows a flat distribution across Layer 1 and Layer 2, with Layers 3 through 5 nearly silent. A noisy day usually means one specific layer has spiked, and the spike points directly at the cause. Layer 3 spiking is almost always a model migration that escaped staging. Layer 5 spiking is almost always a pricing miscalculation in a recently shipped feature. The shape of the spike tells you what to fix before you ever open the incident ticket.
The second habit is writing PR descriptions in terms of which layer they will affect. A change that adds a new tool gets reviewed for Layer 1 schema compatibility. A change that introduces image inputs gets reviewed for Layer 3 capability coverage. Reviewers stop asking "did you handle errors?" — a vague question that produces vague answers — and start asking "which preflight layer might this break?" — a specific question with specific answers.
The third habit is the most subtle. Once you can see how often each layer rejects something, you start seeing where the cost of preflight has gone too far. If Layer 4 rejects more than two percent of legitimate requests over a week, the patterns are too aggressive and need pruning. If Layer 1 never rejects anything, the schema may have drifted out of alignment with reality. The dashboard becomes a feedback loop on the quality of the preflight design itself, which is the property that turns this from a one-time refactor into a sustained practice.
A small detail that made the dashboard much more useful: I emit a preflight metric for allowed requests too, with layer: "allow". That sounds wasteful but it gives the time-series a denominator. Without it, a drop in denial volume cannot be distinguished from a drop in traffic. With it, the denial rate per layer becomes a stable percentage that is comparable across days, weeks, and feature launches.
Three Traps I Walked Into
The design is straightforward, but rolling it out without breaking traffic took more care than I expected. Three pitfalls cost me a few hours each.
- Over-validation eating valid traffic. My first version of the
tools[].nameregex was strict enough to reject every existing tool that contained a hyphen. Always run a new preflight build against the last seven days of recorded traffic before flipping the switch in production. A simple replay harness pays for itself the first time it catches a false positive. - Front-end and API-boundary schemas drifting apart. If the React form validates with one Zod schema and the Worker validates with a slightly different one, they will diverge within a quarter, and the divergence will be discovered in production. Extract the schemas into a shared
@anthropic-types/preflightpackage consumed by both sides. - Trusting a tokenizer that lies a little. cl100k-base estimates skew low on long Japanese strings. Audit the estimate against
count_tokensonce a month and adjust your safety margin. The five-percent headroom in my code is the result of one such audit cycle, not a number I made up.
A side note on testing strategy. Preflight code that lives at the API boundary is dangerously easy to deploy without a corresponding test suite, because the function looks pure and innocuous. Resist that. The schema and capability matrices change as Anthropic ships new features, and a regression here is invisible until production traffic exercises the broken path. I keep a preflight.fixtures.json file containing roughly two hundred recorded request/response pairs from real traffic, anonymized, and a unit test runs the fixtures through the current preflight on every commit. When Anthropic released the latest model lineup, that fixture suite caught two capability mismatches before they shipped. The investment in fixtures pays back the first time you change a regex without realizing what it was originally there to catch.
The Layer I Did Not Build (Yet)
If I were redesigning this from scratch, there is a sixth layer I would prototype next: anomaly detection on the shape of incoming requests. Schema-level checks catch structural problems, but they say nothing about whether a request looks like a request your system normally serves. A user who suddenly sends a messages array ten times longer than their historical average is rarely a healthy event, even when the array passes Zod. A tool whose input_schema accepts a free-form query field is a phishing surface for adversarial inputs that no static rule will catch.
The pieces are not exotic. A modest baseline can be built from three signals: request size relative to the user's recent average, tool-call frequency relative to the user's recent average, and a content embedding that flags large jumps in semantic distance from previous turns. Layer 6 would not block requests outright; it would tag them, increase their sampling rate in observability, and feed the labels back into the spend cap so that anomalous traffic costs the originating user more aggressively. The point is not to be clever about detecting attacks. The point is to make abusive traffic noticeable in the same dashboard where you already watch the rest of preflight.
I have not built this yet because the first five layers were enough to fix the operational problem I was facing, and there is a discipline in stopping when the symptoms stop. Layered systems become liabilities the moment they exceed the team's capacity to maintain them, and a sixth layer that nobody understands well enough to debug at three in the morning is worse than no layer at all. If your traffic is more adversarial than mine — public APIs, freemium tiers, user-generated tools — Layer 6 is probably the next investment. The first five layers are the foundation that makes Layer 6 worth building, because they remove the noise that would otherwise drown out the anomaly signal.
Where to Start Tomorrow
Shipping all five layers at once is more than most teams have time for. Start with Layer 1, the Zod schema. Drop it into your API handler and let it reject only with a 422 until you are confident in its false-positive rate. The first week alone will surface a measurable difference between "what your front-end thinks is valid" and "what Anthropic actually accepts." Layers 2 through 5 can be added incrementally as the corresponding error categories become visible in your remaining noise.
If you operate in a regulated environment, Layer 4 deserves a deeper look than I have shown here. Pair the regex-based markers with a dedicated PII-detection service or an in-process model, run them in shadow mode for a week, and only then promote to enforcement. The patterns above are the minimum viable version, not the production-grade version.
One more thing before I close. Preflight is not a substitute for retries, circuit breakers, or downstream observability. It does not replace timeouts, idempotency keys, or the rest of your reliability toolkit. What it does is move a class of failures upstream so that the rest of your reliability toolkit is not wasting cycles on failures that should never have occurred. Each piece of the stack does its job better when the layer above it is filtering correctly.
I am still iterating myself — sharpening the token estimate, prototyping a sixth layer for anomaly detection on call patterns. The architecture above is the foundation, not the finish line. If your morning routine includes triaging the same handful of avoidable Claude API errors, I hope this saves you the same week of frustration that finally pushed me to write it. Thank you for reading.