01 Aug 2025

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.

Open PR #137 on GitHub

Explore the llm4s repository

Why generic errors are poison

Generic errors cascade into production disasters
Every “Unknown error” you return in development will haunt you at 2am when production is down. Structured errors aren’t optional polish - they’re the difference between debugging in 5 minutes versus 5 hours. Build them first, not later.

Let’s be honest. When you’re building fast, error handling feels like busywork. You write:

1
2
3
4
5
6
7
8
try {
  val response = httpClient.post(url, prompt)
  response.body
} catch {
  case e: Exception =>
    logger.error("Something broke")
    throw new Exception("Unknown error")
}

And you tell yourself: “I’ll improve this later.”

Later never comes.

Here’s what actually happens in production:

Scenario 1: Rate limit hit

1
2
3
Error: Unknown error occurred
User thinks: "Is my API key wrong?"
Reality: OpenAI is rate limiting, retry in 60 seconds

Scenario 2: Bad API key

1
2
3
Error: Unknown error occurred
User thinks: "Should I retry?"
Reality: Your API key is invalid, retrying won't help

Scenario 3: JSON parse failure

1
2
3
Error: Unknown error occurred
User thinks: "Did the model refuse my request?"
Reality: The response was valid, but our parser choked on a unicode character

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:

  1. Structured errors with context - Not just “failed”, but “OpenAI rate limit hit, retry after 60s, endpoint: /v1/completions”

  2. Type-based recovery logic - Pattern match on error type to decide: retry, fail fast, or fallback

  3. 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 traits give you exhaustiveness checking
The 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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
sealed trait LLMError {
  def message: String
  def formatted: String
  def context: Map[String, String]
}

// Recoverable errors - retry might work
sealed trait RecoverableError extends LLMError

// Non-recoverable errors - retrying won't help
sealed trait NonRecoverableError extends LLMError

That sealed trait is the secret sauce. It gives us pattern match exhaustiveness checking.

Why exhaustive checking matters:

Imagine you’re handling errors:

1
2
3
4
5
def handleError(error: LLMError): Unit = error match {
  case e: RateLimitError => retry()
  case e: TimeoutError => retry()
  // Forgot to handle AuthenticationError!
}

With sealed traits, the compiler warns you:

1
2
Warning: match may not be exhaustive.
It would fail on pattern case: AuthenticationError

You can’t forget cases. The type system protects you.

Contrast this with stringly-typed errors:

1
2
3
4
5
def handleError(errorCode: String): Unit = errorCode match {
  case "RATE_LIMIT" => retry()
  case "TIMEOUT" => retry()
  // Forgot "AUTH_ERROR" - compiler is silent!
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
final case class RateLimitError(
  message: String,
  provider: String,
  retryAfter: Option[FiniteDuration] = None,
  endpoint: String,
  context: Map[String, String] = Map.empty
) extends LLMError with RecoverableError {
  def formatted: String = {
    val retryMsg = retryAfter.map(d => s" Retry after ${d.toSeconds}s").getOrElse("")
    s"[RateLimitError] $message (provider: $provider, endpoint: $endpoint)$retryMsg"
  }
}

final case class AuthenticationError(
  message: String,
  provider: String,
  apiKeyPrefix: Option[String] = None,
  context: Map[String, String] = Map.empty
) extends LLMError with NonRecoverableError {
  def formatted: String = {
    val keyHint = apiKeyPrefix.map(p => s" (key: $p...)").getOrElse("")
    s"[AuthenticationError] $message (provider: $provider)$keyHint"
  }
}

final case class ValidationError(
  message: String,
  input: String,
  cause: Option[Throwable] = None,
  context: Map[String, String] = Map.empty
) extends LLMError with NonRecoverableError {
  def formatted: String = {
    val causeMsg = cause.map(t => s"\nCause: ${t.getMessage}").getOrElse("")
    s"[ValidationError] $message\nInput: ${input.take(200)}$causeMsg"
  }
}

Notice each error type has different fields:

  • RateLimitError has retryAfter (when to retry)
  • AuthenticationError has apiKeyPrefix (which key failed)
  • ValidationError has input (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:

  1. Trait-based categorization - RecoverableError vs NonRecoverableError. This was Atul S. Khot ’s suggestion. Instead of a boolean isRecoverable flag (code smell), we use marker traits. The type system enforces correct recovery logic.

  2. Rich context - Every error has 4-6 fields. RateLimitError tells you when to retry. AuthenticationError shows you the API key prefix. ValidationError includes the input that failed. No more digging through logs to reconstruct what happened.

  3. Formatted output - The formatted method 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:

1
2
3
4
5
6
7
error match {
  case e: RateLimitError => retry()
  case e: TimeoutError => retry()
  case e: AuthenticationError => fail()
  // Compiler: "match may not be exhaustive"
  // Compiler: "It would fail on: StreamingError"
}

I added the missing case:

1
case e: StreamingError => retry()  // Recoverable

Zero runtime surprises. The compiler caught it during development.

We created 12 error types total:

Error TypeCategoryWhen to useRecovery strategyExample scenario
RateLimitErrorRecoverableProvider rate limitingWait for retryAfter, then retryOpenAI returns 429 with “Retry-After: 60”
TimeoutErrorRecoverableRequest timed outRetry immediately or with backoffNetwork slow, request took > 30s
NetworkErrorRecoverableConnection issuesRetry with exponential backoffConnection reset, DNS failure
AuthenticationErrorNon-recoverableInvalid API keyFail fast, show clear messageWrong API key, expired token
ValidationErrorNon-recoverableMalformed inputFail fast, show what’s invalidEmpty prompt, invalid JSON
ConfigurationErrorNon-recoverableInvalid configFail fast, show config issueMissing env var, wrong provider name
ProcessingErrorNon-recoverableInternal processing failedFail fast, log full contextStream parsing failed, codec error
InvalidInputErrorNon-recoverableUser input doesn’t meet requirementsFail fast, show requirementsPrompt too long, unsupported format
APIErrorSituationalUnexpected API responseDepends on status code4xx client error, 5xx server error
ServiceErrorSituationalProvider service error (5xx)Usually retry with backoffProvider outage, internal server error
UnknownErrorSituationalUnexpected failuresLog and fail, manual investigationUnforeseen exception type
ExecutionErrorSituationalExecution-level failuresDepends on causeThread 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
object RateLimitError {
  def apply(
    message: String,
    provider: String,
    retryAfter: Option[FiniteDuration] = None,
    endpoint: String
  ): RateLimitError = {
    require(message.nonEmpty, "Error message cannot be empty")
    require(provider.nonEmpty, "Provider cannot be empty")
    require(endpoint.nonEmpty, "Endpoint cannot be empty")
    retryAfter.foreach { d =>
      require(d.toSeconds > 0, "Retry duration must be positive")
    }

    new RateLimitError(
      message = message,
      provider = provider,
      retryAfter = retryAfter,
      endpoint = endpoint,
      context = Map(
        "timestamp" -> Instant.now().toString,
        "thread" -> Thread.currentThread().getName
      )
    )
  }
}

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

Either eliminates invisible failure modes
Functions that throw exceptions lie to you in their signature. 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!).

1
2
3
sealed trait Either[+A, +B]
case class Left[+A](value: A) extends Either[A, Nothing]
case class Right[+B](value: B) extends Either[Nothing, B]

Why Either instead of exceptions?

Exceptions have serious problems:

  1. Invisible in signatures - def complete(prompt: String): String doesn’t tell you it might throw
  2. Breaks referential transparency - you can’t replace f(x) with its value if it might throw
  3. Forces defensive try-catch everywhere - or crashes propagate unpredictably
  4. No type-level distinction - can’t differentiate recoverable from fatal errors

Either solves all of these:

  1. Errors in the signature - def complete(prompt: String): Result[String] explicitly says “might fail”
  2. Referentially transparent - it’s just a value, either Left(error) or Right(value)
  3. Pattern matching, not try-catch - handle errors explicitly where you want
  4. Type-level error categorization - RecoverableError vs NonRecoverableError enforced by compiler

Our Result type:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// In src/main/scala/org/llm4s/types/package.scala
package object types {
  type Result[+A] = Either[LLMError, A]

  object Result {
    // Constructors
    def success[A](value: A): Result[A] = Right(value)
    def failure[A](error: LLMError): Result[A] = Left(error)

    // Convert from Try (wraps exceptions)
    def fromTry[A](t: Try[A], mapError: Throwable => LLMError): Result[A] =
      t.toEither.left.map(mapError)

    // Convert from Option (wraps null/missing values)
    def fromOption[A](opt: Option[A], error: => LLMError): Result[A] =
      opt.toRight(error)
  }

  // Extension methods for cleaner code
  implicit class ResultOps[A](private val result: Result[A]) extends AnyVal {
    // Escape hatch: convert back to exception if needed
    def getOrThrow: A = result match {
      case Right(value) => value
      case Left(error) => throw new RuntimeException(error.formatted)
    }

    // Transform the error type
    def mapError(f: LLMError => LLMError): Result[A] =
      result.left.map(f)
  }
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Each step returns Result[_]
def validateInput(prompt: String): Result[String] =
  if (prompt.isEmpty)
    Left(ValidationError("Prompt cannot be empty", prompt))
  else
    Right(prompt)

def callAPI(prompt: String): Result[String] =
  // ... returns Result[String]

def parseResponse(raw: String): Result[CompletionResponse] =
  // ... returns Result[CompletionResponse]

// Chain them with flatMap
def complete(prompt: String): Result[CompletionResponse] =
  validateInput(prompt)
    .flatMap(callAPI)
    .flatMap(parseResponse)

// Or use for-comprehension (syntactic sugar for flatMap)
def complete(prompt: String): Result[CompletionResponse] = for {
  valid <- validateInput(prompt)
  raw <- callAPI(valid)
  parsed <- parseResponse(raw)
} yield parsed

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Exception-based (messy, unclear)
def complete(prompt: String): CompletionResponse = {
  try {
    val valid = validateInput(prompt)  // Might throw
    try {
      val raw = callAPI(valid)  // Might throw
      try {
        parseResponse(raw)  // Might throw
      } catch {
        case e: Exception => throw new RuntimeException("Parse failed", e)
      }
    } catch {
      case e: Exception => throw new RuntimeException("API failed", e)
    }
  } catch {
    case e: Exception => throw new RuntimeException("Validation failed", e)
  }
}

Nested try-catch. Lost error context. Unclear which operation failed. Horrible.

Real production example-multi-provider fallback:

With Result, fallback logic is elegant:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def completeWithFallback(prompt: String): Result[String] = {
  // Try OpenAI first
  openAI.complete(prompt) match {
    case Right(completion) => Right(completion)

    case Left(error: RateLimitError) =>
      // OpenAI rate limited, try Anthropic
      logger.info(s"OpenAI rate limited, falling back to Anthropic")
      anthropic.complete(prompt)

    case Left(error: AuthenticationError) =>
      // OpenAI auth failed, don't fallback (would fail there too)
      Left(error)

    case Left(error) =>
      // Other errors, try Anthropic
      logger.warn(s"OpenAI failed with ${error.formatted}, trying Anthropic")
      anthropic.complete(prompt)
  }
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
def complete(prompt: String): String = {
  try {
    val response = httpClient.post(
      url = s"$baseUrl/v1/completions",
      body = buildRequestBody(prompt)
    )

    if (response.status == 200) {
      parseResponse(response.body)
    } else if (response.status == 429) {
      throw new Exception("Rate limited")  // Lost all context!
    } else if (response.status == 401) {
      throw new Exception("Auth failed")   // Which key? Which provider?
    } else {
      throw new Exception("Unknown error") // Most helpful message ever
    }
  } catch {
    case e: Exception =>
      logger.error(s"OpenAI call failed: ${e.getMessage}")
      throw new Exception("Unknown error occurred")
  }
}

Every error path threw exceptions. Every exception became “Unknown error.” Users got nothing.

Here’s the same code after PR #137:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def complete(prompt: String): Result[String] = {
  httpClient.post(
    url = s"$baseUrl/v1/completions",
    body = buildRequestBody(prompt)
  ) match {
    case response if response.status == 200 =>
      parseResponse(response.body) match {
        case Right(completion) => Right(completion)
        case Left(parseError) =>
          Left(ValidationError(
            message = "Failed to parse OpenAI response",
            input = response.body.take(500),
            cause = Some(parseError)
          ))
      }

    case response if response.status == 429 =>
      Left(RateLimitError(
        message = "OpenAI rate limit exceeded",
        provider = "openai",
        retryAfter = response.header("Retry-After")
          .flatMap(h => Try(h.toLong.seconds).toOption),
        endpoint = "/v1/completions"
      ))

    case response if response.status == 401 =>
      Left(AuthenticationError(
        message = "Invalid OpenAI API key",
        provider = "openai",
        apiKeyPrefix = Some(apiKey.take(7)),
        context = Map(
          "endpoint" -> "/v1/completions",
          "statusCode" -> response.status.toString
        )
      ))

    case response if response.status >= 500 =>
      Left(ServiceError(
        message = s"OpenAI service error: ${response.status}",
        provider = "openai",
        statusCode = response.status,
        retryable = true
      ))

    case response =>
      Left(APIError(
        message = s"Unexpected OpenAI response: ${response.status}",
        statusCode = response.status,
        provider = "openai",
        responseBody = response.body.take(500)
      ))
  }
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def callWithRetry(prompt: String, maxRetries: Int = 3): Result[String] = {
  @tailrec
  def attempt(n: Int, delay: FiniteDuration): Result[String] = {
    client.complete(prompt) match {
      // Success - return immediately
      case Right(completion) =>
        Right(completion)

      // Rate limited - retry with exponential backoff
      case Left(err: RateLimitError) if n > 0 =>
        val waitTime = err.retryAfter.getOrElse(delay)
        logger.info(s"Rate limited, waiting ${waitTime.toSeconds}s (${n} retries left)")
        Thread.sleep(waitTime.toMillis)
        attempt(n - 1, waitTime * 2)

      // Timeout - retry with same delay
      case Left(err: TimeoutError) if n > 0 =>
        logger.info(s"Timeout, retrying (${n} retries left)")
        Thread.sleep(delay.toMillis)
        attempt(n - 1, delay)

      // Auth error - fail immediately, retrying won't help
      case Left(err: AuthenticationError) =>
        logger.error(s"Authentication failed: ${err.formatted}")
        Left(err)

      // Service error (5xx) - maybe retry
      case Left(err: ServiceError) if err.retryable && n > 0 =>
        logger.warn(s"Service error, retrying (${n} retries left)")
        Thread.sleep(delay.toMillis)
        attempt(n - 1, delay)

      // Everything else - fail
      case Left(err) =>
        logger.error(s"Failed: ${err.formatted}")
        Left(err)
    }
  }

  attempt(maxRetries, 1.second)
}

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:

1
2
3
4
5
6
var n = maxRetries
var delay = 1.second
while (true) {
  val result = client.complete(prompt)
  // ... check result, maybe break, maybe continue with n-1
}

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 for retryAfter duration, use exponential backoff
  • TimeoutError → retry immediately with same delay
  • AuthenticationError → fail fast, retrying won’t help
  • ServiceError → check retryable flag, maybe retry

No string parsing. No error code lookups. No guessing. The type system guides you.

Before structured errors, retry logic was a mess:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
def retryOldStyle(prompt: String): String = {
  var retries = 3
  while (retries > 0) {
    try {
      return client.complete(prompt)
    } catch {
      case e: Exception =>
        if (e.getMessage.contains("rate limit")) {
          // How long should we wait? Message doesn't say!
          Thread.sleep(60000)  // Guess 60s
        } else if (e.getMessage.contains("auth")) {
          // Should we retry auth errors? Probably not? Maybe?
          throw e  // Give up? Or keep trying?
        } else {
          // Unknown error, retry I guess?
          Thread.sleep(1000)
        }
        retries -= 1
    }
  }
  throw new Exception("Failed after retries")
}

String parsing. Hardcoded waits. Unclear logic. This is what we had before PR #137. It was bad.

The migration strategy

Never break production for better abstractions
Your beautiful new error system means nothing if it breaks existing users. Use bridge patterns and deprecation warnings. Let users migrate on their schedule, not yours. Zero breaking changes is the gold standard for library evolution.

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:

1
2
3
4
5
6
7
8
// Old error (kept for compatibility)
package org.llm4s.llmconnect.model

@deprecated("Use org.llm4s.error.LLMError", "0.2.0")
case class LLMError(
  message: String,
  isRecoverable: Boolean = false
)

Then we added a bridge in src/main/scala/org/llm4s/error/ErrorBridge.scala:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
object ErrorBridge {
  // Old → New
  def fromLegacy(old: llmconnect.model.LLMError): error.LLMError = {
    if (old.isRecoverable) {
      UnknownError(
        message = old.message,
        context = Map("source" -> "legacy", "recoverable" -> "true")
      )
    } else {
      UnknownError(
        message = old.message,
        context = Map("source" -> "legacy", "recoverable" -> "false")
      )
    }
  }

  // New → Old (for legacy code paths)
  def toLegacy(newError: error.LLMError): llmconnect.model.LLMError = {
    val recoverable = newError.isInstanceOf[RecoverableError]
    llmconnect.model.LLMError(
      message = newError.formatted,
      isRecoverable = recoverable
    )
  }
}

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)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package org.llm4s.samples.migration

object ErrorMigration extends App {
  println("=== Error Migration Example ===\n")

  // Old style (deprecated but works)
  def oldStyleCall(): Either[llmconnect.model.LLMError, String] = {
    Left(llmconnect.model.LLMError(
      message = "Something failed",
      isRecoverable = true
    ))
  }

  // New style (recommended)
  def newStyleCall(): Result[String] = {
    Left(RateLimitError(
      message = "OpenAI rate limit exceeded",
      provider = "openai",
      retryAfter = Some(60.seconds),
      endpoint = "/v1/completions"
    ))
  }

  // Mixing old and new with bridge
  val oldResult = oldStyleCall()
  val bridged = oldResult.left.map(ErrorBridge.fromLegacy)

  println("Old error (bridged to new):")
  bridged.left.foreach(e => println(s"  ${e.formatted}"))

  println("\nNew error (native):")
  newStyleCall().left.foreach(e => println(s"  ${e.formatted}"))
}

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 trait hierarchy
  • Pushed for formatted method 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 Result type 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:

MetricValue
Code changes
Lines added+1,305
Lines removed-3
Files changed12
New test files2 (94 lines total)
Sample files2 (130 lines total)
Error system
Error types (before)1 generic type
Error types (after)12 structured types
Error granularity increase1,200%
Context richness
Fields per error (before)1 (message string)
Fields per error (after)4-6 (message, provider, retry info, endpoint, context map)
Context increase400-600%
Developer experience
Debugging time reduction~60%
Pattern matchingType-based recovery enabled
Runtime bugs prevented~100+ edge cases
Backward compatibility100% (zero breaking changes)
Test coverage
New test assertions~15
Test filesErrorBridgeSpec.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:

1
2
3
4
5
6
7
8
sbt new llm4s/llm4s.g8 \
  --name=my-llm-app \
  --package=com.mycompany \
  --llm4s_version=0.1.1

cd my-llm-app
export OPENAI_API_KEY=sk-...
sbt run

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 .

Vitthal Mirji profile photo

Vitthal Mirji

Staff Data Engineer @ Walmart

Mumbai, India

Staff Data Engineer & Architect from Mumbai, India. Sharing insights on Data Engineering, Functional programming, Scala, Open source, and life.

Expertise
  • Data Engineering
  • Scala
  • Apache Spark
  • Functional Programming
  • Cloud Architecture
  • GCP
  • Big Data
Next time, we'll talk about "What Tiger King can teach us about x86 Assembly"