Software Engineering, Data Engineering, Functional Programming, Scala, GNU/Linux, Data, AI/ML, and other things
21Sep 2025
Safety refactor: The P1 streaming bug, wrong errors, and 47 try-catch blocks
“Streaming not yet complete.”
That was the error message. But JSON parsing had failed. The streaming was fine. Our parser choked on the response.
Atul/Rory found the bug. In StreamingResponseHandler.scala, when JSON parsing failed, we didn’t call handleError() before returning. So users saw “Streaming not yet complete” instead of “JSON parse error at position 342.”
Misleading error messages. In production. For weeks.
That’s when Atul/Rory did a full audit. They found 47 try-catch blocks across the codebase. Every single one was swallowing error context.
We’d built this beautiful error hierarchy (Posts 1-4). Structured errors. Smart constructors. Type safety. And then we were throwing it all away with try { ... } catch { case e: Exception => ... }.
Time to fix it. All of it.
PR #260
was massive: 57 files changed, +1,707 lines added, -1,967 lines removed. We eliminated every try-catch block, built safety utilities, fixed the streaming bug, and the code got simpler (-260 net lines).
This is the story of how we brought functional error handling to every corner of llm4s
.
Try-catch blocks that don’t properly signal completion will show the wrong error to users. Always set completion flags in error paths AND success paths.
Let me show you the exact bug. This is from StreamingResponseHandler.scala before the fix:
defhandleStreamChunk(chunk:String):Unit={try{valparsed=parseJson(chunk)// Might throw JsonParseException
valdelta=parsed.choices.head.deltaif(delta.content.isDefined){accumulator.append(delta.content.get)}if(delta.finish_reason.isDefined){isComplete=true}}catch{casee:JsonParseException=>logger.error(s"Parse failed: ${e.getMessage}")// BUG: We don't call handleError() here!
// BUG: We don't set isComplete = true!
// So the caller checks isComplete and sees false
// Then shows: "Streaming not yet complete"
}}defgetResult():Either[LLMError, Completion]={if(!isComplete){Left(StreamingError("Streaming not yet complete"))// WRONG!
}else{Right(buildCompletion(accumulator.content))}}
See the bug? When parsing fails:
We log the error
We don’t call handleError()
We don’t set isComplete = true
The caller checks isComplete, sees false
Shows “Streaming not yet complete” (totally wrong!)
Users had no idea JSON parsing failed. They thought streaming was broken.
defhandleStreamChunk(chunk:String):Unit={Safety.fromTry(parseJson(chunk)).leftMap{throwable=>ValidationError(message="Failed to parse streaming JSON response",input=chunk.take(500),cause=Some(throwable))}.tap{caseLeft(error)=>// FIXED: Always call handleError on parse failure
handleError(error)isComplete=true// FIXED: Mark as complete on error
caseRight(parsed)=>// Process normally
valdelta=parsed.choices.head.deltaif(delta.content.isDefined){accumulator.append(delta.content.get)}if(delta.finish_reason.isDefined){isComplete=true}}}defgetResult():Either[LLMError, Completion]={if(!isComplete){Left(StreamingError("Streaming not yet complete"))}else{lastErrormatch{caseSome(err)=>// If we hit an error during streaming, return it
Left(err)caseNone=>// Normal completion
Right(buildCompletion(accumulator.content))}}}
Now:
Parse failures call handleError() immediately
isComplete is set to true on errors
Users see “JSON parse error” (correct!)
The actual error is preserved and returned
One bug. But it revealed a systemic problem: try-catch everywhere.
Every provider. Every utility. Every piece of code that did I/O or parsing.
Common patterns:
Pattern 1: Lost context
1
2
3
4
5
6
7
8
try{valresponse=httpClient.post(url,body)parseResponse(response)}catch{casee:Exception=>logger.error("Request failed")// What failed? Which provider? Which endpoint?
thrownewException("Unknown error")// All context lost
}
Pattern 2: Resource leaks
1
2
3
4
5
6
7
valconnection=getConnection()try{valresult=query(connection)result}finally{connection.close()// What if close() throws? What if query() threw and we have an error to propagate?
}
Pattern 3: Error type erasure
1
2
3
4
5
6
7
try{parseJson(response)}catch{casee:JsonParseException=>thrownewException("Parse failed")// Lost the parse exception details
casee:IOException=>thrownewException("IO failed")// Lost the IO exception details
casee:Exception=>thrownewException("Unknown")// Catch-all that hides everything
}
We’d built all these structured error types (Posts 1-3). Then we were converting them all to generic exceptions.
Time to fix it. All 47 blocks.
Building the Safety utilities
Functional pattern
Build Safety utilities once. Every try-catch becomes Safety.fromTry. Centralized error mapping means consistent, structured errors everywhere. One place to handle exceptions, everywhere else uses Result types.
We created src/main/scala/org/llm4s/core/safety/Safety.scala (56 lines):
traitErrorMapper{defmapError(throwable:Throwable):LLMError}objectErrorMapper{// Default mapper - use when no context available
implicitvaldefault:ErrorMapper=newErrorMapper{defmapError(throwable:Throwable):LLMError=throwablematch{case_:JsonParseException=>ValidationError(message="JSON parsing failed",input="",cause=Some(throwable))case_:IOException=>NetworkError(message=s"Network error: ${throwable.getMessage}",provider="unknown",cause=Some(throwable))case_:TimeoutException=>TimeoutError(message="Operation timed out",provider="unknown",duration=30.seconds)case_=>UnknownError(message=throwable.getMessage,cause=Some(throwable))}}// Provider-specific mappers
defforProvider(provider:String):ErrorMapper=newErrorMapper{defmapError(throwable:Throwable):LLMError=throwablematch{case_:JsonParseException=>ValidationError(message=s"Failed to parse $provider response",input="",cause=Some(throwable),context=Map("provider"->provider))case_:HttpExceptionifthrowable.getMessage.contains("429")=>RateLimitError(message=s"$provider rate limit exceeded",provider=provider,retryAfter=None,endpoint="unknown")case_:HttpExceptionifthrowable.getMessage.contains("401")=>AuthenticationError(message=s"$provider authentication failed",provider=provider,apiKeyPrefix=None)case_=>APIError(message=s"$provider error: ${throwable.getMessage}",statusCode=500,provider=provider,responseBody=None)}}}
Now error mapping is centralized. Context is preserved. Types are structured.
The complete Safety package: error accumulation and async support
The Safety object we built wasn’t just about fromTry and safely. The complete package (org.llm4s.core.safety) provides functional error handling patterns that scale.
Error accumulation with ValidatedNec
What is ValidatedNec? It’s a Cats data type that lets you accumulate all errors instead of failing on the first one. “Nec” stands for “Non-Empty Chain” - guaranteeing at least one error if validation fails.
What are Cats?Cats
is a functional programming library for Scala that provides abstractions like Validated, Either, Functor, Monad, etc. Think of it as the standard library for functional Scala.
Why this matters: When you’re validating configuration or user input, you want to show all problems at once, not make users play whack-a-mole fixing one error at a time.
Here’s what we added to Safety.scala:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
importcats.data.ValidatedNecimportcats.syntax.apply._objectSafety{// ... fromTry and safely ...
/**
* Sequence a list of Results and accumulate ALL errors
* Instead of failing on first error, collect every single one
*/defsequenceV[A](xs:List[Result[A]]):ValidatedNec[LLMError, List[A]]=xs.foldRight(cats.data.Validated.validNec[LLMError, List[A]](Nil)){(r,acc)=>(cats.data.Validated.fromEither(r).toValidatedNec,acc).mapN(_::_)}}
What’s happening here?
foldRight: Process the list from right to left
validNec[LLMError, List[A]](Nil): Start with an empty valid list
fromEither(r).toValidatedNec: Convert each Result[A] (which is Either[LLMError, A]) into ValidatedNec
mapN(_ :: _): If both are valid, cons the value onto the list. If either has errors, accumulate them.
The result: ValidatedNec[LLMError, List[A]] - either all values succeeded, or you get a chain of ALL errors.
// Validate multiple API keys at startup
valkeys=List("OPENAI_API_KEY","ANTHROPIC_API_KEY","GROQ_API_KEY")valvalidations:List[Result[String]]=keys.map{key=>sys.env.get(key).toRight(ConfigurationError(s"Missing environment variable: $key"))}Safety.sequenceV(validations)match{caseValid(allKeys)=>println(s"All ${allKeys.length} API keys configured")// Proceed with initialization
caseInvalid(errors)=>// Show ALL missing keys at once
println("Configuration errors:")errors.toList.foreach(err=>println(s" - ${err.message}"))sys.exit(1)}
objectSafety{objectfuture{/**
* Convert a Future[A] to Future[Result[A]]
* Captures both async failures and maps them to LLMError
*/deffromFuture[A](fa:Future[A])(implicitec:ExecutionContext,em:ErrorMapper=DefaultErrorMapper):Future[Result[A]]=fa.map(Right(_)).recover{caset=>Left(em(t))}/**
* Wrap a synchronous computation in Future with error mapping
* Note: Future.apply already captures synchronous exceptions
*/defsafely[A](thunk:=>A)(implicitec:ExecutionContext,em:ErrorMapper=DefaultErrorMapper):Future[Result[A]]=Future(thunk).map(Right(_)).recover{caset=>Left(em(t))}}}
fromFuture usage:
1
2
3
4
5
6
7
8
9
10
11
12
13
// Making an async HTTP call
valfutureResponse:Future[HttpResponse]=httpClient.post(url,body)valresult:Future[Result[HttpResponse]]=Safety.future.fromFuture(futureResponse)result.map{caseRight(response)=>println("Success!")caseLeft(NetworkError(msg,cause,provider))=>println(s"Network error from $provider: $msg")caseLeft(error)=>println(s"Other error: ${error.formatted}")}
safely usage (for sync code in async context):
1
2
3
4
// Parse JSON in a Future context
valparsed:Future[Result[User]]=Safety.future.safely{jsonParser.parse(responseBody).as[User]}
The key insight: Don’t wrap in Try inside Future. Future.apply already captures synchronous exceptions, so we just need to map them to LLMError using recover.
The tracing design: composable observability
While building Safety utilities, I also designed the tracing system foundation. This isn’t about Shubham’s implementation - this is about the functional composition patterns that make tracing composable.
The EnhancedTracing trait
What is a trait? In Scala, a trait is like a Java interface but can have concrete methods. It defines a contract that different implementations must follow.
The tracing system uses a sealed trait hierarchy for events and a trait for tracing operations:
Every operation returns Result[Unit] - tracing can fail without crashing the app
Type-safe event types (not just strings)
Composable - you can combine multiple tracers
Sealed trait event hierarchy
What are sealed traits? When you mark a trait as sealed, the compiler knows all possible subtypes (they must be in the same file). This enables exhaustive pattern matching - the compiler warns you if you miss a case.
Here’s the event hierarchy (modules/core/src/main/scala/org/llm4s/trace/TraceEvent.scala):
valconsole=newEnhancedConsoleTracing()vallangfuse=newEnhancedLangfuseTracing(publicKey,secretKey)valcombined=TracingComposer.combine(console,langfuse)// Now every trace goes to both console AND Langfuse
combined.traceCompletion(completion,"gpt-4o")
Filter events:
1
2
3
4
5
// Only trace errors, ignore everything else
valerrorsOnly=TracingComposer.filter(langfuse){case_:TraceEvent.ErrorOccurred=>truecase_=>false}
Transform events:
1
2
3
4
5
6
7
8
// Add environment tag to all events
valwithEnv=TracingComposer.transform(langfuse){event=>eventmatch{casee:TraceEvent.CompletionReceived=>e.copy(content=s"[${sys.env.getOrElse("ENV","dev")}] ${e.content}")caseother=>other}}
Pattern: Composite tracing implementation
Here’s how composition is implemented internally:
1
2
3
4
5
6
7
8
9
10
privateclassCompositeTracing(tracers:Vector[EnhancedTracing])extendsEnhancedTracing{deftraceEvent(event:TraceEvent):Result[Unit]={valresults=tracers.map(_.traceEvent(event))// Collect all errors, succeed if at least one succeeds
valerrors=results.collect{caseLeft(error)=>error}if(errors.size==results.size)Left(errors.head)elseRight(())}// Other methods delegate to traceEvent with appropriate event construction
}
Key insight: If ANY tracer succeeds, the operation succeeds. This means if Langfuse is down, console tracing still works.
Pattern: No-op tracing for production toggle
1
2
3
4
5
classEnhancedNoOpTracingextendsEnhancedTracing{deftraceEvent(event:TraceEvent):Result[Unit]=Right(())deftraceAgentState(state:AgentState):Result[Unit]=Right(())// ... all methods just return Right(())
}
Usage:
1
2
3
4
5
6
7
8
valtracer=if(sys.env.contains("ENABLE_TRACING")){newEnhancedLangfuseTracing(/* ... */)}else{newEnhancedNoOpTracing()}// Code doesn't change, tracing is just a no-op in production if disabled
tracer.traceCompletion(completion,model)
Zero runtime cost when tracing is disabled. The no-op implementation is inlined by the JVM.
Pattern: Console tracing with ANSI colors
This is Shubham’s implementation of my design - show structured output with color:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
classEnhancedConsoleTracingextendsEnhancedTracing{privatevalCYAN="\u001b[36m"privatevalGREEN="\u001b[32m"privatevalRED="\u001b[31m"privatevalRESET="\u001b[0m"deftraceEvent(event:TraceEvent):Result[Unit]=Try{eventmatch{casee:TraceEvent.CompletionReceived=>println(s"${CYAN}[COMPLETION]${RESET}${e.model}")println(s" ${GREEN}${e.content.take(100)}...${RESET}")casee:TraceEvent.ErrorOccurred=>println(s"${RED}[ERROR]${RESET}${e.error.getMessage}")// ... other cases
}}.toEither.left.map(err=>UnknownError(err.getMessage,err))}
When you run locally, you see color-coded traces in your terminal. In production with Langfuse, structured JSON goes to the observability platform.
Same interface, different implementation. That’s the power of traits.
Refactoring OpenAIClient
Let’s see the transformation. Here’s OpenAIClient before (130 lines of try-catch hell):
packageorg.llm4s.core.safetyimportscala.util.control.NonFatalimportorg.llm4s.types.ResultobjectUsingOps{/**
* Use a resource and ensure it's closed, even if an exception occurs
*/defusing[A<:AutoCloseable, B](resource:=>A)(f:A=>B)(implicitmapper:ErrorMapper):Result[B]={Safety.safely{valr=resourcetry{f(r)}finally{try{if(r!=null)r.close()}catch{caseNonFatal(e)=>// Log but don't fail on close errors
logger.warn(s"Error closing resource: ${e.getMessage}")}}}}/**
* Use multiple resources
*/defusing2[A<:AutoCloseable, B<:AutoCloseable, C](resource1:=>A,resource2:=>B)(f:(A,B)=>C)(implicitmapper:ErrorMapper):Result[C]={using(resource1){r1=>using(resource2){r2=>f(r1,r2)}}.flatten}}
// Before (50 lines of try-finally)
defsearch(embedding:Vector,limit:Int):Seq[Document]={valconn=dataSource.getConnection()try{valstmt=conn.prepareStatement("SELECT id, content, embedding <=> ? AS distance FROM documents ORDER BY distance LIMIT ?")try{stmt.setObject(1,embedding.toArray)stmt.setInt(2,limit)valrs=stmt.executeQuery()try{valresults=newArrayBuffer[Document]()while(rs.next()){results+=Document(id=rs.getString("id"),content=rs.getString("content"),distance=rs.getDouble("distance"))}results.toSeq}finally{rs.close()}}finally{stmt.close()}}finally{conn.close()}}// After (20 lines with UsingOps)
defsearch(embedding:Vector,limit:Int):Result[Seq[Document]]={implicitvalmapper:ErrorMapper=ErrorMapper.defaultfor{conn<-UsingOps.using(dataSource.getConnection()){c=>Right(c)}stmt<-UsingOps.using(conn.prepareStatement(sql)){s=>s.setObject(1,embedding.toArray)s.setInt(2,limit)Right(s)}results<-UsingOps.using(stmt.executeQuery()){rs=>valbuffer=newArrayBuffer[Document]()while(rs.next()){buffer+=Document(id=rs.getString("id"),content=rs.getString("content"),distance=rs.getDouble("distance"))}Right(buffer.toSeq)}}yieldresults}
Cleaner. Safer. Composable.
Refactoring all 8 LLM model providers
We applied the Safety pattern to every LLM provider in llm4s
:
Provider
Try-catch blocks eliminated
Operations refactored
What changed
Lines saved
OpenAI
7
Vision API, Audio generation, Embeddings, Streaming, Standard completions
130 → 60 lines (-54%)
All errors now structured (RateLimitError, AuthenticationError, etc.)
Anthropic
5
Streaming, Vision, Standard completions
147 → 83 lines (-44%)
Parse failures show exact JSON position
Azure
4
All operations (unified with OpenAI, they share API)
Simplified via OpenAI base class
Inherits OpenAI error handling
OpenRouter
6
Routing logic, Provider fallback
172 → 114 lines (-34%)
Multi-provider errors accumulated
Ollama
3
Local model calls
Simplified local error handling
Connection errors properly typed
Groq
3
Fast inference
Network timeout handling improved
Speed-sensitive error recovery
Mistral
4
European provider endpoints
Auth errors include endpoint context
GDPR-compliant error logging
Perplexity
3
Search-augmented generation
Search failures vs LLM failures separated
Clear error attribution
Uniform pattern across all LLM model providers:
Safety.fromTry for JSON parsing
Safety.safely for potentially throwing operations
ErrorMapper.forProvider(providerName) for context-aware errors
Structured error types from our hierarchy (Post 1-3)
For-comprehension composition (no nested try-catch)
Total elimination: 47 try-catch blocks → 0 (all replaced with Safety utilities)
Image processing hardened
LocalImageProcessor had unsafe operations:
Before:
1
2
3
4
5
6
7
8
9
10
defresize(image:BufferedImage,width:Int,height:Int):BufferedImage={// What if width or height is negative?
// What if width * height overflows?
// What if image is null?
valresized=newBufferedImage(width,height,image.getType)valgraphics=resized.createGraphics()graphics.drawImage(image,0,0,width,height,null)graphics.dispose()resized}
defresize(image:BufferedImage,width:Int,height:Int):Result[BufferedImage]={if(image==null){Left(InvalidInputError(message="Image cannot be null",field="image",value="null",expected=Some("Valid BufferedImage")))}elseif(width<=0||height<=0){Left(InvalidInputError(message="Image dimensions must be positive",field="width/height",value=s"$width x $height",expected=Some("Positive integers")))}elseif(width>10000||height>10000){Left(InvalidInputError(message="Image dimensions too large",field="width/height",value=s"$width x $height",expected=Some("Less than 10000x10000")))}else{Safety.safely{valresized=newBufferedImage(width,height,image.getType)valgraphics=resized.createGraphics()graphics.drawImage(image,0,0,width,height,null)graphics.dispose()resized}(ErrorMapper.default)}}
Add Scalafix rules to prevent future try-catch additions. Make the build fail if someone bypasses your Safety utilities. Automation beats code review for consistency.
Final post: Production patterns and lessons learned. What worked. What didn’t. Real patterns you can copy into your codebase. That’s where we synthesize everything.