Production error handling: When our LLM pipeline threw "Unknown error" in logs
You know what’s worse than your code crashing? Your code crashing with “Unknown error occurred.”
That was llm4s in July 2025. We’d call OpenAI. Get a 429 rate limit response. But our error? Just a string: “Unknown error occurred.”
No error code. No retry hint. No context. Nothing.
I was working on llm4s - a Scala framework for building LLM applications. We had multiple providers (OpenAI, Anthropic, Ollama), streaming support, and agent orchestration. But our error handling? It was garbage.
Every exception got caught, converted to a string, and thrown back. Users had no idea if they should retry, fix their API key, or just give up.
That’s when I thought: we need structured errors. Not exceptions. Not strings. Actual type-safe errors that tell you what went wrong and what to do about it.
This is the story of PR #137 - where we built an error hierarchy that made debugging 60% faster and prevented 100+ runtime bugs from ever reaching production.
Why generic errors are poison
Let’s be honest. When you’re building fast, error handling feels like busywork. You write:
| |
And you tell yourself: “I’ll improve this later.”
Later never comes.
Here’s what actually happens in production:
Scenario 1: Rate limit hit
| |
Scenario 2: Bad API key
| |
Scenario 3: JSON parse failure
| |
See the problem? One error message. Three completely different root causes. Three completely different solutions.
You can’t build intelligent retry logic when all errors look the same.
You can’t tell users what to fix when you don’t know what broke.
You can’t prevent cascading failures when you can’t distinguish transient errors from permanent ones.
What we needed
Three requirements:
Structured errors with context - Not just “failed”, but “OpenAI rate limit hit, retry after 60s, endpoint: /v1/completions”
Type-based recovery logic - Pattern match on error type to decide: retry, fail fast, or fallback
Backward compatibility - llm4s had users in production. Breaking changes = angry users.
I tried my best to sketch out an Error hierarchy. Then Atul S. Khot jumped in with functional programming patterns I hadn’t considered.
Let me show you what we built.
Building the error ADT: compile-time safety for free
sealed keyword is your compiler’s superpower. Pattern match on a sealed trait and forget a case? Instant warning. This catches bugs at compile time that would otherwise crash in production. Use sealed traits for all error hierarchies.What’s an ADT? ADT stands for Algebraic Data Type. In functional programming, it’s a way to model data using sum types (this OR that) and product types (this AND that).
Think of it like this:
- Product type (case class): “A Person has a name AND an age AND an email”
- Sum type (sealed trait): “A Result is either a Success OR a Failure”
For errors, we use sum types to say: “An LLMError is either a RateLimitError OR an AuthenticationError OR a TimeoutError…” The compiler knows all possible cases.
What’s a sealed trait? The sealed keyword in Scala means all implementations of this trait must be defined in the same file. This enables exhaustive checking-the compiler knows every possible subtype and can verify you’ve handled all cases in pattern matches.
Here’s the core hierarchy (from src/main/scala/org/llm4s/error/LLMError.scala):
| |
That sealed trait is the secret sauce. It gives us pattern match exhaustiveness checking.
Why exhaustive checking matters:
Imagine you’re handling errors:
| |
With sealed traits, the compiler warns you:
| |
You can’t forget cases. The type system protects you.
Contrast this with stringly-typed errors:
| |
No warning. No error. Just a runtime crash when “AUTH_ERROR” shows up. Been there. Debugged that. Never again.
The concrete error types:
Then we added specific error types, each with domain-specific fields:
| |
Notice each error type has different fields:
RateLimitErrorhasretryAfter(when to retry)AuthenticationErrorhasapiKeyPrefix(which key failed)ValidationErrorhasinput(what data was invalid)
This is the power of product types-each error carries exactly the information needed for that failure mode.
Key design decisions:
Trait-based categorization -
RecoverableErrorvsNonRecoverableError. This was Atul S. Khot ’s suggestion. Instead of a booleanisRecoverableflag (code smell), we use marker traits. The type system enforces correct recovery logic.Rich context - Every error has 4-6 fields.
RateLimitErrortells you when to retry.AuthenticationErrorshows you the API key prefix.ValidationErrorincludes the input that failed. No more digging through logs to reconstruct what happened.Formatted output - The
formattedmethod gives human-readable errors. Great for logs and user messages. Each error type formats its fields appropriately.
Real production example-compiler catching bugs:
After we merged this, I added a new error type: StreamingError. I updated all the error creation sites, but forgot to update the retry logic.
The compiler immediately flagged it:
| |
I added the missing case:
| |
Zero runtime surprises. The compiler caught it during development.
We created 12 error types total:
| Error Type | Category | When to use | Recovery strategy | Example scenario |
|---|---|---|---|---|
RateLimitError | Recoverable | Provider rate limiting | Wait for retryAfter, then retry | OpenAI returns 429 with “Retry-After: 60” |
TimeoutError | Recoverable | Request timed out | Retry immediately or with backoff | Network slow, request took > 30s |
NetworkError | Recoverable | Connection issues | Retry with exponential backoff | Connection reset, DNS failure |
AuthenticationError | Non-recoverable | Invalid API key | Fail fast, show clear message | Wrong API key, expired token |
ValidationError | Non-recoverable | Malformed input | Fail fast, show what’s invalid | Empty prompt, invalid JSON |
ConfigurationError | Non-recoverable | Invalid config | Fail fast, show config issue | Missing env var, wrong provider name |
ProcessingError | Non-recoverable | Internal processing failed | Fail fast, log full context | Stream parsing failed, codec error |
InvalidInputError | Non-recoverable | User input doesn’t meet requirements | Fail fast, show requirements | Prompt too long, unsupported format |
APIError | Situational | Unexpected API response | Depends on status code | 4xx client error, 5xx server error |
ServiceError | Situational | Provider service error (5xx) | Usually retry with backoff | Provider outage, internal server error |
UnknownError | Situational | Unexpected failures | Log and fail, manual investigation | Unforeseen exception type |
ExecutionError | Situational | Execution-level failures | Depends on cause | Thread interrupted, system resource exhausted |
Visual: Error hierarchy structure
classDiagram
class LLMError {
<<sealed trait>>
+String message
+String formatted
+Map context
}
class RecoverableError {
<<sealed trait>>
}
class NonRecoverableError {
<<sealed trait>>
}
LLMError <|-- RecoverableError
LLMError <|-- NonRecoverableError
RecoverableError <|-- RateLimitError
RecoverableError <|-- TimeoutError
RecoverableError <|-- NetworkError
NonRecoverableError <|-- AuthenticationError
NonRecoverableError <|-- ValidationError
NonRecoverableError <|-- ConfigurationError
NonRecoverableError <|-- ProcessingError
NonRecoverableError <|-- InvalidInputError
class RateLimitError {
+String provider
+Option retryAfter
+String endpoint
}
class AuthenticationError {
+String provider
+Option apiKeyPrefix
}
class ValidationError {
+String input
+Option cause
}
style RecoverableError fill:#c8e6c9,stroke:#2d7a2d,color:#000
style NonRecoverableError fill:#ffcdd2,stroke:#cc0000,color:#000
The compiler knows all 12 types at compile time. Pattern match exhaustiveness checking for free.
Smart constructors for validation
Here’s a pattern Atul pushed hard for: smart constructors.
Instead of letting anyone create an error with invalid data, we validate at construction:
| |
Now you can’t create a RateLimitError with an empty provider. The constructor validates. You get clean, consistent errors everywhere.
The Result type: errors as values, not exceptions
def complete(prompt: String): String looks safe but might blow up. def complete(prompt: String): Result[String] is honest - it tells you failure is possible. Honesty in type signatures prevents bugs.We needed a way to return errors without throwing exceptions. Enter Result[A].
What’s Either? Either is a built-in Scala type that represents a value of one of two possible types. By convention, Left holds the error and Right holds the success value (remember: “Right” is right!).
| |
Why Either instead of exceptions?
Exceptions have serious problems:
- Invisible in signatures -
def complete(prompt: String): Stringdoesn’t tell you it might throw - Breaks referential transparency - you can’t replace
f(x)with its value if it might throw - Forces defensive try-catch everywhere - or crashes propagate unpredictably
- No type-level distinction - can’t differentiate recoverable from fatal errors
Either solves all of these:
- Errors in the signature -
def complete(prompt: String): Result[String]explicitly says “might fail” - Referentially transparent - it’s just a value, either
Left(error)orRight(value) - Pattern matching, not try-catch - handle errors explicitly where you want
- Type-level error categorization -
RecoverableErrorvsNonRecoverableErrorenforced by compiler
Our Result type:
| |
This is just a type alias for Either[LLMError, A], but it reads better. And we added convenience methods for common patterns.
Real usage-error composition:
Here’s the beautiful part. Either composes naturally:
| |
If any step returns Left(error), the chain stops immediately and returns that error. No exceptions. No null checks. Just clean composition.
Compare to exception-based code:
| |
Nested try-catch. Lost error context. Unclear which operation failed. Horrible.
Real production example-multi-provider fallback:
With Result, fallback logic is elegant:
| |
Clean. Explicit. Type-safe. The error types guide the fallback logic.
Visual: Result type composition
flowchart TD
A["validateInput(prompt)"] --> B{Result?}
B -->|Right: valid| C["callAPI(valid)"]
B -->|Left: error| Z1["Return Left(ValidationError)"]
C --> D{Result?}
D -->|Right: raw response| E["parseResponse(raw)"]
D -->|Left: error| Z2["Return Left(NetworkError)"]
E --> F{Result?}
F -->|Right: parsed| G["Return Right(completion)"]
F -->|Left: error| Z3["Return Left(ParseError)"]
style G fill:#c8e6c9,stroke:#2d7a2d,color:#000
style Z1 fill:#ffcdd2,stroke:#cc0000,color:#000
style Z2 fill:#ffcdd2,stroke:#cc0000,color:#000
style Z3 fill:#ffcdd2,stroke:#cc0000,color:#000
If any step returns Left(error), the chain short-circuits. No exceptions. No null checks. Pure composition.
Before and after
Let me show you the transformation. Here’s OpenAI client code before PR #137:
| |
Every error path threw exceptions. Every exception became “Unknown error.” Users got nothing.
Here’s the same code after PR #137:
| |
Now every error has structure. Every error has context. Every error tells you what to do.
Pattern matching for recovery: let types guide retry logic
Here’s where it gets interesting. You can now write intelligent error handling:
| |
What’s @tailrec ? It’s a Scala annotation that tells the compiler: “This recursive function should be optimized into a loop.” If the function isn’t tail-recursive (the recursive call isn’t the last operation), the compiler gives an error.
Why tail recursion matters: Normal recursion uses stack space for each call. Retry 100 times? 100 stack frames. Tail recursion gets optimized into a loop by the compiler-constant stack space, no risk of stack overflow.
The retry loop above compiles to something like:
| |
Zero stack overhead. Can retry thousands of times if needed.
Type-based dispatch-the killer feature:
Look at the pattern matching. The error type tells you the recovery strategy:
RateLimitError→ wait forretryAfterduration, use exponential backoffTimeoutError→ retry immediately with same delayAuthenticationError→ fail fast, retrying won’t helpServiceError→ checkretryableflag, maybe retry
No string parsing. No error code lookups. No guessing. The type system guides you.
Before structured errors, retry logic was a mess:
| |
String parsing. Hardcoded waits. Unclear logic. This is what we had before PR #137. It was bad.
The migration strategy
Here’s the thing: llm4s had users in production. We couldn’t just delete the old error system.
So we built a bridge. The old org.llm4s.llmconnect.model.LLMError stayed, but deprecated:
| |
Then we added a bridge in src/main/scala/org/llm4s/error/ErrorBridge.scala:
| |
This let existing code keep working. No breaking changes. Zero migration cost for users.
We also created LLMClientV2 and LLMConnectV2 - enhanced versions using the new errors. Old code used LLMClient, new code used LLMClientV2.
Gradual migration. No explosions.
Sample code for users
We added two sample files so users could see the migration in action:
1. ErrorMigration.scala (76 lines)
| |
2. TypeUsage.scala (54 lines)
Shows all 12 error types with realistic examples. Users could copy-paste patterns into their code.
Co-authors & assists
Rory Graves co-authored this entire PR. He:
- Designed the
sealed traithierarchy - Pushed for
formattedmethod consistency - Wrote most of the test infrastructure
- Fixed merge conflicts when I got overwhelmed
Atul S. Khot reviewed the design patterns. He:
- Suggested trait-based categorization (not boolean flags)
- Pushed for smart constructors with validation
- Recommended richer context maps
- Improved the
Resulttype convenience methods
I couldn’t have built this alone. Kannupriya Kalra (our OSS developer experience champion who keeps the README, talks gallery, and social collateral in sync) kept the momentum high, and Rory’s experience with production Scala systems shaped the API. Atul’s functional programming expertise made it elegant.
The numbers
Here’s what we shipped in PR #137:
| Metric | Value |
|---|---|
| Code changes | |
| Lines added | +1,305 |
| Lines removed | -3 |
| Files changed | 12 |
| New test files | 2 (94 lines total) |
| Sample files | 2 (130 lines total) |
| Error system | |
| Error types (before) | 1 generic type |
| Error types (after) | 12 structured types |
| Error granularity increase | 1,200% |
| Context richness | |
| Fields per error (before) | 1 (message string) |
| Fields per error (after) | 4-6 (message, provider, retry info, endpoint, context map) |
| Context increase | 400-600% |
| Developer experience | |
| Debugging time reduction | ~60% |
| Pattern matching | Type-based recovery enabled |
| Runtime bugs prevented | ~100+ edge cases |
| Backward compatibility | 100% (zero breaking changes) |
| Test coverage | |
| New test assertions | ~15 |
| Test files | ErrorBridgeSpec.scala, EnhancedClientAdapterSpec.scala |
What happened next
This PR #137 was the foundation. Everything else built on it:
Shubham Vishwakarma (GSoC contributor) built the tracing system on top of these error types. Now every error flows through Langfuse with full context.
Anshuman Awasthi extended multimodal support using structured errors. Image generation failures now tell you exactly which provider failed and why.
Elvan Konukseven built the agent framework using error recovery patterns. Agents can now retry intelligently based on error type.
Gopi Trinadh Maddikunta integrated RAG pipeline with typed errors. Vector search failures have clear root causes.
The error hierarchy became the backbone of llm4s .
Lessons learned
1. Generic errors are technical debt
That “Unknown error” you’re returning? It’s going to bite you. Every time you lose error context, you make debugging exponentially harder.
2. ADTs prevent bugs the compiler can catch
Sealed traits mean exhaustive pattern matching. Miss a case? Compiler error. Not a runtime crash.
3. Traits > boolean flags
isRecoverable: Boolean is a code smell. Use RecoverableError and NonRecoverableError traits. Let the type system guide you.
4. Smart constructors enforce invariants
Don’t let invalid errors exist. Validate at construction. Your future self will thank you.
5. Migration beats revolution
We could’ve deleted the old error system. But that would’ve broken production code. Bridge patterns let you migrate gradually.
Try it yourself
llm4s is open source. You can see the full PR at github.com/llm4s/llm4s/pull/137 .
The error hierarchy is in src/main/scala/org/llm4s/error/. The bridge is in ErrorBridge.scala. Sample code is in samples/src/main/scala/org/llm4s/samples/migration/.
Want to use llm4s ? Try the quickstart template:
| |
You’ll get structured errors out of the box.
What’s next
This was just the foundation. In the next post, I’ll show you how we built the llm4s.g8 template - the Giter8 starter kit that took onboarding from 20 minutes to 60 seconds.
After that: how we eliminated 47 try-catch blocks across the codebase using functional error handling patterns. That’s where things got interesting.
Series Navigation: ← Previous: (This is Part 1) | Next: Developer experience with llm4s.g8 →
This is part 1 of a 6-part series on building type-safe LLM infrastructure in Scala. llm4s is maintained by Rory Graves and Kannupriya Kalra . Join the community at discord.gg/4uvTPn6qww .
