I’d built on top of PR #137
(the error hierarchy from Post 1). But while I was working, other PRs merged. Now my branch was 15 commits behind main. Merge conflicts everywhere.
I stared at the diff. Red and green lines mixed together. I honestly didn’t know where to start.
That’s when Rory Graves
messaged me: “I’ll handle the rebase. Continue what you were doing”
24 hours later, PR #197
was born - a cleaned-up, conflict-free version of my work. But more than that, it incorporated feedback from Atul S. Khot
that made the error hierarchy actually elegant.
This is the story of how we refined the llm4s
error system with smart constructors, eliminated code smells, and wrote 201 lines of tests. The code got simpler and more capable.
The smart constructor pattern: making invalid states impossible
Common mistake
Public case class constructors let anyone create garbage data. Make the constructor private, add a validated factory method. Discover bugs during development, not in production logs.
Here’s what Atul S. Khot
taught me. Instead of public case class constructors, use private constructors with validation.
What’s a smart constructor? It’s a design pattern where you make the class constructor private (so no one can call it directly), then provide a factory method (usually named apply) that validates inputs before creating the object. The “smart” part is that the factory refuses to create invalid objects.
Why private? In Scala, when you mark a constructor private, it can only be called from within the class itself or its companion object. This forces everyone to use your validated factory method instead of bypassing validation.
What’s a companion object? In Scala, an object with the same name as a class is called a companion object. It’s like a singleton that has special access to the class’s private members. It’s commonly used for factory methods.
valgarbage=RateLimitError(message="",// Empty! Useless in logs
provider="",// Empty! Don't know which provider failed
retryAfter=Some(-999.seconds),// Negative! When should we retry? Never?
endpoint="",// Empty! Don't know what endpoint hit the limit
context=Map("random"->"junk")// Inconsistent! No timestamp, no thread info
)
It compiles. It runs. It creates completely useless error objects that make debugging impossible.
// src/main/scala/org/llm4s/error/RateLimitError.scala
finalcaseclassRateLimitErrorprivate(// <- Note: "private" constructor!
message:String,provider:String,retryAfter:Option[FiniteDuration],endpoint:String,context:Map[String, String])extendsLLMErrorwithRecoverableError{defformatted:String={valretryMsg=retryAfter.map(d=>s" Retry after ${d.toSeconds}s").getOrElse("")s"[RateLimitError] $message (provider: $provider, endpoint: $endpoint)$retryMsg"}}objectRateLimitError{// <- Companion object with factory method
defapply(message:String,provider:String,retryAfter:Option[FiniteDuration]=None,endpoint:String):RateLimitError={// Validation using require()
require(message.nonEmpty,"Error message cannot be empty")require(provider.nonEmpty,"Provider cannot be empty")require(endpoint.nonEmpty,"Endpoint cannot be empty")retryAfter.foreach{duration=>require(duration.toSeconds>0,s"Retry duration must be positive, got: ${duration.toSeconds}s")}// Auto-populate context with consistent structure
newRateLimitError(message=message,provider=provider,retryAfter=retryAfter,endpoint=endpoint,context=Map("timestamp"->Instant.now().toString,"thread"->Thread.currentThread().getName,"errorType"->"RateLimitError"))}// Convenience constructor from HTTP response
deffromResponse(provider:String,endpoint:String,response:HttpResponse):RateLimitError={valretryAfter=response.header("Retry-After").flatMap(h=>Try(h.toLong.seconds).toOption)apply(message=s"Rate limit exceeded for $provider",provider=provider,retryAfter=retryAfter,endpoint=endpoint)}}
What’s require()? It’s a built-in Scala function that throws IllegalArgumentException if the condition is false. It’s perfect for precondition checks in factory methods. If someone passes bad data, require() blows up immediately with your custom error message.
Look at what we gained:
Benefit
What it does
Why it matters
Example
Validation at construction
Can’t create invalid error objects
Discover problems during development, not production
RateLimitError(message = "") → IllegalArgumentException: Error message cannot be empty
Auto-populated context
Every error gets timestamp, thread, error type
No one forgets to add context, consistent observability
What happens if you try to create invalid errors now?
1
2
3
4
5
6
7
8
9
10
valbadError=RateLimitError(message="",// Empty message
provider="",retryAfter=Some(-5.seconds),endpoint="")// Exception in thread "main" java.lang.IllegalArgumentException:
// requirement failed: Error message cannot be empty
// at scala.Predef$.require(Predef.scala:268)
//atRateLimitError$.apply(RateLimitError.scala:42)
Compile time: This still compiles (the types are correct).
Runtime: It fails immediately at the call site with a clear error.
That’s the key. You discover the problem during development, not after deployment. The stack trace points directly to where you called RateLimitError(...) with bad data. You fix it immediately.
// providers/openai/src/main/scala/OpenAIClient.scala
defhandleRateLimit(response:HttpResponse):Result[CompletionResponse]={// Before smart constructors: manual error construction
// val error = RateLimitError(
// message = "Rate limit exceeded", // Hardcoded message
// provider = "openai", // Hardcoded provider
// retryAfter = None, // Forgot to parse Retry-After header!
// endpoint = "/v1/chat/completions", // Hardcoded endpoint
// context = Map.empty // Forgot to add context!
// )
// After smart constructors: convenience constructor does it all
valerror=RateLimitError.fromResponse(provider="openai",endpoint="/v1/chat/completions",response=response)// Automatically:
// - Parses "Retry-After" header if present
// - Generates clear message
// - Populates context with timestamp, thread, error type
Left(error)}
One line. No repetition. No mistakes. The smart constructor handles all the details.
Eliminating boolean flags: marker traits for compile-time safety
FP best practice
Boolean flags are runtime checks. Marker traits are compile-time guarantees. Use the type system to categorize, not runtime if statements. The compiler will catch your mistakes.
Atul S. Khot
’s critical feedback: “Replace isRecoverable: Boolean with traits. Boolean flags are a code smell.”
Why are boolean flags a problem? They push decisions to runtime when the compiler could enforce correctness at compile time. Every if (flag) check is a runtime branch that could be a compile-time type check instead.
The old way (boolean flags):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
sealedtraitLLMError{defmessage:StringdefisRecoverable:Boolean// Runtime flag
}// Every error type needs to set this flag
finalcaseclassRateLimitError(...)extendsLLMError{valisRecoverable=true// Easy to forget or set wrong!
}finalcaseclassAuthenticationError(...)extendsLLMError{valisRecoverable=false// What if someone changes this by mistake?
}// Using it
errormatch{caseeife.isRecoverable=>retry()// Runtime check
casee=>failFast()}
Problems with this approach:
1. Runtime checks instead of compile-time safety
1
if(error.isRecoverable){...}//Runtimedecision
The compiler can’t help you. You might forget to check the flag. You might check it wrong.
2. Can’t enforce exhaustiveness
1
2
3
4
5
errormatch{caseeife.isRecoverable=>retry()// What if someone forgets the else case?
}//Compiler:"Thisisfine!"(It'snotfine!)
3. Flags can be set incorrectly
1
2
3
finalcaseclassRateLimitError(...)extendsLLMError{valisRecoverable=false// BUG! Should be true!
}
The compiler won’t catch this. It’s just a boolean value. Tests might miss it. Users discover it in production.
The new way (marker traits):
What’s a marker trait? It’s a trait with no methods or fields-its only purpose is to mark a type as belonging to a category. It uses the type system for classification instead of runtime values.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
sealedtraitLLMError{defmessage:Stringdefformatted:Stringdefcontext:Map[String, String]}// Marker traits for categorization
sealedtraitRecoverableErrorextendsLLMErrorsealedtraitNonRecoverableErrorextendsLLMError// Concrete errors pick their category via inheritance
finalcaseclassRateLimitError(...)extendsLLMErrorwithRecoverableErrorfinalcaseclassTimeoutError(...)extendsLLMErrorwithRecoverableErrorfinalcaseclassNetworkError(...)extendsLLMErrorwithRecoverableErrorfinalcaseclassAuthenticationError(...)extendsLLMErrorwithNonRecoverableErrorfinalcaseclassValidationError(...)extendsLLMErrorwithNonRecoverableErrorfinalcaseclassProcessingError(...)extendsLLMErrorwithNonRecoverableError
The with RecoverableError part is called mixin composition-you’re mixing the marker trait into the error type. Now RateLimitError isn’t just an LLMError, it’s also a RecoverableError. The type system knows this at compile time.
No runtime flag check. The type system handles it. The compiler verifies your logic.
2. Exhaustiveness checking
1
2
3
4
5
6
errormatch{casee:RecoverableError=>retry()// Forgot NonRecoverableError case?
}// Warning: match may not be exhaustive.
//Itwouldfailon:NonRecoverableError
The compiler warns you. You fix it before running the code.
3. Can’t set the category wrong
1
2
3
4
finalcaseclassRateLimitError(...)extendsLLMErrorwithNonRecoverableError// Later: someone reviews the PR
// "Wait, rate limit errors ARE recoverable. Fix this."
//Thetypeisvisibleintheclassdeclaration,easytoreview
Real production scenario-why this matters:
Before trait-based categorization, we had this bug:
1
2
3
4
5
6
7
8
9
// Someone added a new error type
finalcaseclassContentFilterError(...)extendsLLMError{valisRecoverable=true// Thought it was recoverable
}// Retry logic
if(error.isRecoverable){retryWithBackoff()// This retries content filter errors!
}
The problem? Content filtering errors aren’t recoverable. If the LLM refuses to generate content because it violates content policy, retrying won’t help. The input is the problem, not a transient issue.
But the boolean flag was set to true, so we kept retrying uselessly. Users saw 3-5 retry attempts before we gave up. Wasted time. Wasted tokens. Poor user experience.
After switching to marker traits:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
finalcaseclassContentFilterError(...)extendsLLMErrorwithNonRecoverableError// The type system prevents retries
errormatch{casee:ContentFilterError=>// Specific handling: explain to user what was filtered
returnLeft(e)// Don't retry
casee:RecoverableError=>retryWithBackoff()// ContentFilterError doesn't match here!
casee:NonRecoverableError=>returnLeft(e)// ContentFilterError caught here instead
}
Now the categorization is enforced by types. You can’t accidentally retry a non-recoverable error.
Getting more specific with pattern matching:
The beautiful thing about marker traits? You can match at any level of specificity:
errormatch{// Most specific: match exact error type
casee:RateLimitError=>valwaitTime=e.retryAfter.getOrElse(60.seconds)logger.info(s"Rate limited, waiting ${waitTime.toSeconds}s")Thread.sleep(waitTime.toMillis)retry()casee:TimeoutError=>// Different retry strategy for timeouts
logger.warn(s"Timeout after ${e.timeout.toSeconds}s, retrying immediately")retry()casee:AuthenticationError=>// Don't retry auth errors - they won't succeed
logger.error(s"Authentication failed: ${e.apiKeyPrefix}")failFast(s"Fix your API key: ${e.apiKeyPrefix}")// Less specific: match by category
casee:RecoverableError=>// Catch-all for other recoverable errors we didn't handle above
logger.info(s"Recoverable error: ${e.formatted}")retry()casee:NonRecoverableError=>// Catch-all for non-recoverable errors
logger.error(s"Non-recoverable error: ${e.formatted}")failFast(e.formatted)}
The compiler verifies this is exhaustive. Every possible LLMError is either:
A specific type we handle explicitly (RateLimitError, TimeoutError, AuthenticationError), OR
Caught by one of the trait-based catch-alls (RecoverableError, NonRecoverableError)
Testing the categorization:
With marker traits, testing is straightforward:
1
2
3
4
5
6
7
8
9
test("RateLimitError should be recoverable"){valerror=RateLimitError("Rate limited","openai",None,"/v1/chat")errorshouldBea[RecoverableError]}test("AuthenticationError should be non-recoverable"){valerror=AuthenticationError("Invalid API key","openai",None)errorshouldBea[NonRecoverableError]}
The type system is the truth. Tests just verify it.
The new error types
PR #197
added missing error types based on real production scenarios:
finalcaseclassProcessingErrorprivate(message:String,operation:String,input:Option[String],cause:Option[Throwable],context:Map[String, String])extendsLLMErrorwithNonRecoverableError{defformatted:String={valinputSnippet=input.map(i=>s"\nInput: ${i.take(200)}...").getOrElse("")valcauseMsg=cause.map(t=>s"\nCause: ${t.getMessage}").getOrElse("")s"[ProcessingError] $message during operation: $operation$inputSnippet$causeMsg"}}objectProcessingError{defapply(message:String,operation:String,input:Option[String]=None,cause:Option[Throwable]=None):ProcessingError={require(message.nonEmpty,"Message cannot be empty")require(operation.nonEmpty,"Operation cannot be empty")newProcessingError(message=message,operation=operation,input=input,cause=cause,context=Map("timestamp"->Instant.now().toString,"operation"->operation))}}
2. InvalidInputError (when user input is malformed)
Test your smart constructors thoroughly. Verify they reject invalid inputs AND accept valid ones. These tests are executable documentation showing exactly how the API works.
Here’s the real quality bar: 201 lines of tests in LLMErrorSpec.scala.
classLLMErrorSpecextendsAnyFunSuitewithMatchers{test("RateLimitError: should require non-empty message"){assertThrows[IllegalArgumentException]{RateLimitError(message="",// Invalid
provider="openai",endpoint="/v1/completions")}}test("RateLimitError: should require non-empty provider"){assertThrows[IllegalArgumentException]{RateLimitError(message="Rate limited",provider="",// Invalid
endpoint="/v1/completions")}}test("RateLimitError: should reject negative retry duration"){assertThrows[IllegalArgumentException]{RateLimitError(message="Rate limited",provider="openai",retryAfter=Some(-5.seconds),// Invalid
endpoint="/v1/completions")}}test("RateLimitError: should accept valid inputs"){valerror=RateLimitError(message="OpenAI rate limit exceeded",provider="openai",retryAfter=Some(60.seconds),endpoint="/v1/completions")error.messageshouldBe"OpenAI rate limit exceeded"error.providershouldBe"openai"error.retryAftershouldBeSome(60.seconds)error.endpointshouldBe"/v1/completions"error.contextshouldcontainkey"timestamp"error.contextshouldcontainkey"thread"}test("RateLimitError: should auto-populate context"){valerror=RateLimitError(message="Rate limited",provider="openai",endpoint="/v1/completions")error.contextshouldcontainkey"timestamp"error.contextshouldcontainkey"thread"error.contextshouldcontainkey"errorType"error.context("errorType")shouldBe"RateLimitError"}}
Testing categorization:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
test("RecoverableError: should include expected error types"){valrateLimitError=RateLimitError("msg","openai",None,"/v1/chat")valtimeoutError=TimeoutError("msg","openai",30.seconds)valnetworkError=NetworkError("msg","openai",None)rateLimitErrorshouldBea[RecoverableError]timeoutErrorshouldBea[RecoverableError]networkErrorshouldBea[RecoverableError]}test("NonRecoverableError: should include expected error types"){valauthError=AuthenticationError("msg","openai",None)valvalidationError=ValidationError("msg","input",None)valprocessingError=ProcessingError("msg","parse",None,None)authErrorshouldBea[NonRecoverableError]validationErrorshouldBea[NonRecoverableError]processingErrorshouldBea[NonRecoverableError]}
test("RateLimitError: formatted should include all context"){valerror=RateLimitError(message="OpenAI rate limit exceeded",provider="openai",retryAfter=Some(60.seconds),endpoint="/v1/completions")valformatted=error.formattedformattedshouldinclude("RateLimitError")formattedshouldinclude("OpenAI rate limit exceeded")formattedshouldinclude("provider: openai")formattedshouldinclude("endpoint: /v1/completions")formattedshouldinclude("Retry after 60s")}test("AuthenticationError: formatted should mask API key"){valerror=AuthenticationError(message="Invalid API key",provider="openai",apiKeyPrefix=Some("sk-proj-12345"))valformatted=error.formattedformattedshouldinclude("AuthenticationError")formattedshouldinclude("Invalid API key")formattedshouldinclude("sk-proj-12345")formattedshouldnotinclude"sk-proj-12345678901234567890"// Full key never shown
}
These tests are executable documentation. They show exactly how the API should be used.
The merge conflict resolution
Remember: this all started because PR #180 had merge conflicts.
Shubham built Langfuse integration for llm4s. Langfuse is an observability platform that tracks LLM calls, traces execution, and helps debug production issues.
The structured errors made this possible:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Shubham's tracing code
deflogError(error:LLMError,trace:Trace):Unit={// Every error has consistent context thanks to smart constructors
valerrorEvent=ErrorEvent(timestamp=error.context("timestamp"),// Auto-populated by smart constructor
thread=error.context("thread"),// Auto-populated
errorType=error.context("errorType"),// Auto-populated
message=error.formatted,// Consistent formatting
recoverable=error.isInstanceOf[RecoverableError],// Type-based categorization
metadata=error.context// Full context map
)langfuse.logEvent(trace.id,errorEvent)}
Without smart constructors? Shubham would’ve had to handle missing context, inconsistent timestamps, and empty error messages. The auto-populated context maps mean every error that flows through Langfuse has complete information.
Real example from production: when debugging a rate limit issue, Shubham could see in Langfuse:
Exact timestamp of each rate limit error
Which thread hit the limit
Which provider and endpoint
Retry-after duration
Full stack trace
All because the error object had everything. No manual logging. No missing data.
Anshuman added image generation support (DALL-E, Stable Diffusion). Images have constraints: dimensions must be specific values (256x256, 512x512, 1024x1024), formats must be PNG or JPEG, sizes must be under limits.
// Anshuman's image error types
finalcaseclassInvalidImageDimensionsErrorprivate(message:String,requested:(Int,Int),supported:List[(Int, Int)],provider:String,context:Map[String, String])extendsLLMErrorwithNonRecoverableError{defformatted:String={valsupportedStr=supported.map{case(w,h)=>s"${w}x${h}"}.mkString(", ")val(w,h)=requesteds"[InvalidImageDimensionsError] ${w}x${h} not supported by $provider. Use one of: $supportedStr"}}objectInvalidImageDimensionsError{defapply(requested:(Int,Int),supported:List[(Int, Int)],provider:String):InvalidImageDimensionsError={val(width,height)=requestedrequire(width>0&&height>0,"Dimensions must be positive")require(supported.nonEmpty,"Supported dimensions list cannot be empty")newInvalidImageDimensionsError(message=s"Invalid dimensions: ${width}x${height}",requested=requested,supported=supported,provider=provider,context=Map("timestamp"->Instant.now().toString,"requestedWidth"->width.toString,"requestedHeight"->height.toString,"provider"->provider))}}
When a user requests 640x480 but OpenAI only supports 256x256, 512x512, 1024x1024:
// Elvan's agent retry logic
defexecuteWithRetry[A](operation:=>Result[A],maxRetries:Int=3):Result[A]={@tailrecdefattempt(retriesLeft:Int):Result[A]={operationmatch{caseRight(value)=>Right(value)caseLeft(error:RecoverableError)ifretriesLeft>0=>// Type-based decision: retry recoverable errors
errormatch{casee:RateLimitError=>// Wait for retry-after duration
valwaitTime=e.retryAfter.getOrElse(60.seconds)Thread.sleep(waitTime.toMillis)casee:TimeoutError=>// Exponential backoff for timeouts
valbackoff=(4-retriesLeft)*2// 2s, 4s, 6s
Thread.sleep(backoff*1000)case_=>// Other recoverable errors: wait 1 second
Thread.sleep(1000)}attempt(retriesLeft-1)caseLeft(error:NonRecoverableError)=>// Type-based decision: don't retry non-recoverable errors
Left(error)caseLeft(error:RecoverableError)=>// Out of retries
Left(error)}}attempt(maxRetries)}
The type system guides the retry logic. RecoverableError vs NonRecoverableError determines whether to retry. Specific error types (RateLimitError, TimeoutError) get custom retry strategies. Everything else gets a simple backoff.
Before marker traits, this would’ve been a mess of if statements checking error codes or messages.
// Gopi's RAG error types
finalcaseclassEmbeddingGenerationErrorprivate(message:String,document:String,provider:String,cause:Option[Throwable],context:Map[String, String])extendsLLMErrorwithRecoverableError{defformatted:String={valdocSnippet=document.take(100)valcauseMsg=cause.map(t=>s" Cause: ${t.getMessage}").getOrElse("")s"[EmbeddingGenerationError] Failed to generate embedding for document: $docSnippet...$causeMsg"}}finalcaseclassContextSizeExceededErrorprivate(message:String,requestedTokens:Int,maxTokens:Int,provider:String,context:Map[String, String])extendsLLMErrorwithNonRecoverableError{defformatted:String=s"[ContextSizeExceededError] Requested $requestedTokens tokens exceeds $provider limit of $maxTokens"}
When Gopi’s RAG pipeline fails, the error explains exactly what went wrong:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Embedding generation failed
Left(EmbeddingGenerationError(message="Embedding API returned 500",document="...",provider="openai",cause=Some(IOException("Connection reset"))))// Too much context
Left(ContextSizeExceededError(message="Context too large",requestedTokens=150000,maxTokens=128000,provider="gpt-4"))
Users don’t see “something went wrong.” They see “Requested 150000 tokens exceeds gpt-4 limit of 128000.” They know to reduce context size or switch models.
The common thread:
All contributors built on the error hierarchy we created in PR #197
. They didn’t have to think about:
How to structure errors
How to validate error data
How to categorize recoverable vs non-recoverable
How to format error messages
How to populate context
The patterns were already there. They just extended them for their specific domains. That’s good infrastructure-it enables future work without getting in the way.
sbt new llm4s/llm4s.g8
cd my-llm-app
exportOPENAI_API_KEY=sk-...
sbt run
The errors are already integrated. You’ll get structured error handling out of the box.
What’s next
We had structured errors. We had smart constructors. But across the llm4s
codebase, we still had 47 try-catch blocks.
Next post: How we eliminated every single one using the Safety refactor (PR #260
). That’s where we fixed the P1 streaming bug and unified error handling across 8 LLM providers.