The Enum That Panics: Surviving a Claude API You Didn't Compile Against

rust dev.to

TL;DR — Anthropic can ship a new content-block, stop-reason, or model id at any time, and a Rust deserializer that pattern-matches on a closed enum will panic or error on the unknown variant. crimson-crab addresses this by making every wire enum non-exhaustive with an Unknown catch-all that round-trips raw JSON instead of failing, plus escape hatches for brand-new beta fields. It's an independent, unaffiliated SDK, now at v0.2.1, with a real fix in that release for how parse::<T>() handles recursive types and text-less responses.

It's 3 a.m., or it might as well be. Your Claude integration has been quietly parsing messages for months. Then a deploy goes out — not yours, theirs — and a response comes back with a content-block type your deserializer has never met. Your Rust service, which has been rock solid, panics. Not a graceful error. Not a 500 with a log line you can grep. A panic, because somewhere in your match statement there's an implicit assumption that the set of variants you saw during development is the set of variants that will ever exist.

This is not a hypothetical. It's the normal lifecycle of any client built against a model API. Anthropic adds a new stop reason. A new content-block kind for a new modality. A new field on a streaming event. None of that breaks the wire format in a way a human would call "breaking" — it's additive, it's exactly the kind of change API providers are supposed to be free to make. But additive at the JSON level is not the same as additive at the type level, and that gap is where your service goes down.

Why "just add a match arm later" doesn't save you

The naive fix is: ship a v1 that covers today's variants, and patch the enum the moment Anthropic adds a new one. This fails for the obvious reason — you don't control Anthropic's release schedule, and you will not patch your enum before their new stop reason hits your production traffic. But it fails for a subtler reason too: a closed enum with serde's default behavior doesn't just mis-handle the new variant, it refuses to deserialize the whole message. One unrecognized tag and the entire response — text you could have shown the user, tool calls you could have executed — is gone, replaced by a deserialization error or, if someone reached for unwrap upstream, a panic that takes the request handler down with it.

The temptation is to catch the error and retry, or to fall back to treating the response as opaque text. Both are workarounds around a design problem, not fixes to it. What you actually want is a client where "field I don't recognize" is a normal, first-class outcome — not an exception path you have to remember to write defensively at every call site.

How crimson-crab treats "unknown" as a value, not a failure

crimson-crab is a Rust SDK for Claude — an independent open-source project, not affiliated with Anthropic — and its answer to this problem is structural, not a try/catch bolted on afterward. Every wire enum in the crate — content-block kind, stop reason, streaming event type, and so on — is declared #[non_exhaustive] and carries an explicit Unknown catch-all variant. When the deserializer meets a tag it has never seen, it doesn't error and it doesn't drop data. It stores the raw JSON payload inside Unknown and moves on. If you re-serialize that message — say, to log it, cache it, or forward it somewhere — the unrecognized block comes back out exactly as it went in, byte for byte, because nothing lossy happened to it in the middle.

The practical effect on your code is that a match over a crimson-crab enum is required by the compiler to have an arm for the unknown case — because the type is non-exhaustive, you literally cannot write an exhaustive match and forget it. That single compiler-enforced habit is what turns "new variant arrives in prod" from an outage into a data point. Your handler for Unknown can log the raw JSON, ship it to a dead-letter queue, or just skip the block and carry on rendering the rest of the message — your choice, but a choice you're forced to make at compile time instead of discovering at runtime.

The same philosophy extends past enums. The model field is a plain string, not a closed enum of known model ids — the crate ships constants like CLAUDE_OPUS_4_8 and CLAUDE_SONNET_5 for convenience, but any string works, so a brand-new model id doesn't require a crate upgrade to use. For server-side features that show up before the SDK has named types for them, there's a .beta("flag") builder method and an .extra_field(key, value) escape hatch — both let you pass through whatever the API newly accepts without waiting on a release.

What this doesn't solve — and where 0.2.1 drew a real line

Forward-compatible enums buy you survival, not understanding. If Anthropic introduces a genuinely new content-block kind — say, a new modality — your code can avoid crashing on it, but it still can't do anything with it until you write that logic yourself and, ideally, until the crate ships a typed variant for it. Unknown is a safety net, not a feature. It stops the panic; it doesn't invent semantics you didn't write.

Structured output is a good example of a related but distinct problem, and it's exactly where crimson-crab's 0.2.1 release earned its keep. The crate's parse::<T>() helper deserializes a response body directly into your own type via a derived JSON Schema. Before 0.2.1, a recursive type — one that refers to itself, which schemars has to represent with $defs/$ref — would silently produce a schema the Anthropic API doesn't accept for structured output, and the failure surfaced as an opaque 400 from the server, far from the code that caused it. 0.2.1 catches this before the request is even sent: parse now returns an Error::Config naming the offending type and the reason, at the point you call it. Ordinary tool schemas are unaffected — references are legal JSON Schema there, this restriction is specific to structured output. The release also fixed a second sharp edge: a response made only of a tool_use block, with no text block at all, used to look like a schema mismatch wrapped around a confusing serde EOF error; now parse reports plainly that there was no text block to parse.

Neither fix is about surviving unknown wire data — they're about giving you an honest, early error instead of a late, misleading one when your own request shape is the problem. That's a different axis from the non-exhaustive-enum story, and it's worth keeping the two straight: one is "the server told you something new," the other is "you asked for something the schema can't express." A client that's robust to the first kind of surprise still needs to be precise about the second.

The retry logic sits in a third category worth naming honestly, too. crimson-crab retries connection errors, timeouts, and 408/409/429/5xx responses with full-jitter exponential backoff, honoring retry-after up to a cap — but streaming retries only apply before the first byte arrives, and the client enforces an idle read timeout rather than a total-request deadline. If a stream stalls mid-response after data has started flowing, that's a case your own application logic still has to detect and handle. No SDK can make "the network stayed up but produced nothing" indistinguishable from success.

The lesson beyond this one crate

If you're integrating with any model API in Rust — Anthropic's or otherwise — the question to ask before you ship isn't "does my code compile against today's schema." It's "what happens when the server sends a tag my enum doesn't have a name for." If the honest answer is "it errors and drops the whole payload," you have a latent outage scheduled for whenever the provider's next release happens to land during your on-call rotation. Non-exhaustive enums with an explicit unknown-data path aren't a Claude-specific trick; they're the general shape of the fix, and crimson-crab is a concrete, checkable example of what it looks like when a crate takes that seriously from the type signatures down.


The project used as the worked example in this column is independent open-source work (MIT OR Apache-2.0). crimson-crab is not affiliated with Anthropic. Every number above is reproducible from a fresh clone.

Source: dev.to

arrow_back Back to Tutorials