17 Aug 2025

Error hierarchy refinement: smart constructors and the code we deleted in prod

PR #180 had merge conflicts.

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.

Open PR #197 on GitHub

Browse llm4s on GitHub

What needed fixing

Remember the error hierarchy from PR #137 ? It worked. But it had rough edges.

Problem 1: Invalid errors could exist

Nothing stopped you from creating garbage:

1
2
3
4
5
6
val badError = RateLimitError(
  message = "",              // Empty message!
  provider = "",             // Empty provider!
  retryAfter = Some(-5.seconds), // Negative duration!
  endpoint = ""              // Empty endpoint!
)

That compiles. It runs. It’s completely useless.

Problem 2: Boolean flags for categorization

The original design used isRecoverable: Boolean:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
case class LLMError(
  message: String,
  isRecoverable: Boolean  // Flag smell
)

// Using it
if (error.isRecoverable) {
  retry()
} else {
  failFast()
}

Atul saw this in review and said: “Boolean flags are a code smell. Use traits.”

He was right. That if statement? It’s runtime logic that could be compile-time.

Problem 3: Context maps were inconsistent

Some errors had context. Some didn’t. There was no pattern:

1
2
3
4
5
RateLimitError(
  message = "Rate limited",
  provider = "openai",
  context = Map.empty  // Why empty?
)

We needed structure.

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.

Before (anyone can create invalid errors):

1
2
3
4
5
6
7
final case class RateLimitError(
  message: String,
  provider: String,
  retryAfter: Option[FiniteDuration],
  endpoint: String,
  context: Map[String, String] = Map.empty
) extends LLMError with RecoverableError

The problem? Nothing stops this:

1
2
3
4
5
6
7
val garbage = 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.

After (validation required):

 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
54
55
56
57
58
59
60
// src/main/scala/org/llm4s/error/RateLimitError.scala
final case class RateLimitError private (  // <- Note: "private" constructor!
  message: String,
  provider: String,
  retryAfter: Option[FiniteDuration],
  endpoint: String,
  context: Map[String, String]
) 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"
  }
}

object RateLimitError {  // <- Companion object with factory method
  def apply(
    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
    new RateLimitError(
      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
  def fromResponse(
    provider: String,
    endpoint: String,
    response: HttpResponse
  ): RateLimitError = {
    val retryAfter = 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:

BenefitWhat it doesWhy it mattersExample
Validation at constructionCan’t create invalid error objectsDiscover problems during development, not productionRateLimitError(message = "")IllegalArgumentException: Error message cannot be empty
Auto-populated contextEvery error gets timestamp, thread, error typeNo one forgets to add context, consistent observabilitycontext("timestamp") = “2025-08-17T14:23:45.123Z” automatically
Convenience constructorsfromResponse handles common HTTP scenariosNo repetitive code, extract retry-after header automaticallyRateLimitError.fromResponse(provider, endpoint, response) one line instead of 10

Real production scenario-the difference it makes:

Before smart constructors, we’d see logs like this:

1
2
3
4
[ERROR] RateLimitError: message="", provider="", endpoint=""
  context: {}
  timestamp: (missing)
  thread: (missing)

Totally useless. We can’t tell:

  • Which provider hit the rate limit?
  • What endpoint was called?
  • When did it happen?
  • Which thread was making the call?

After smart constructors, it’s impossible to create that garbage. Logs look like this:

1
2
3
4
5
6
[ERROR] RateLimitError: OpenAI rate limit exceeded (provider: openai, endpoint: /v1/chat/completions) Retry after 60s
  context: {
    timestamp: 2025-08-17T14:23:45.123Z,
    thread: pool-1-thread-3,
    errorType: RateLimitError
  }

Clear. Actionable. Complete.

What happens if you try to create invalid errors now?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
val badError = 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)
//     at RateLimitError$.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.

Real usage from OpenAI provider:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// providers/openai/src/main/scala/OpenAIClient.scala
def handleRateLimit(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
  val error = 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
sealed trait LLMError {
  def message: String
  def isRecoverable: Boolean  // Runtime flag
}

// Every error type needs to set this flag
final case class RateLimitError(...) extends LLMError {
  val isRecoverable = true  // Easy to forget or set wrong!
}

final case class AuthenticationError(...) extends LLMError {
  val isRecoverable = false  // What if someone changes this by mistake?
}

// Using it
error match {
  case e if e.isRecoverable => retry()  // Runtime check
  case e => failFast()
}

Problems with this approach:

1. Runtime checks instead of compile-time safety

1
if (error.isRecoverable) { ... }  // Runtime decision

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
error match {
  case e if e.isRecoverable => retry()
  // What if someone forgets the else case?
}
// Compiler: "This is fine!" (It's not fine!)

3. Flags can be set incorrectly

1
2
3
final case class RateLimitError(...) extends LLMError {
  val isRecoverable = 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
sealed trait LLMError {
  def message: String
  def formatted: String
  def context: Map[String, String]
}

// Marker traits for categorization
sealed trait RecoverableError extends LLMError
sealed trait NonRecoverableError extends LLMError

// Concrete errors pick their category via inheritance
final case class RateLimitError(...) extends LLMError with RecoverableError
final case class TimeoutError(...) extends LLMError with RecoverableError
final case class NetworkError(...) extends LLMError with RecoverableError

final case class AuthenticationError(...) extends LLMError with NonRecoverableError
final case class ValidationError(...) extends LLMError with NonRecoverableError
final case class ProcessingError(...) extends LLMError with NonRecoverableError

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.

Using it with type-based pattern matching:

1
2
3
4
5
6
7
8
error match {
  case e: RecoverableError =>
    logger.info(s"Recoverable error, retrying: ${e.formatted}")
    retry()
  case e: NonRecoverableError =>
    logger.error(s"Non-recoverable error, failing: ${e.formatted}")
    failFast()
}

Why this is better:

1. Compile-time type checking

1
case e: RecoverableError => retry()  // Compiler knows e is recoverable

No runtime flag check. The type system handles it. The compiler verifies your logic.

2. Exhaustiveness checking

1
2
3
4
5
6
error match {
  case e: RecoverableError => retry()
  // Forgot NonRecoverableError case?
}
// Warning: match may not be exhaustive.
// It would fail on: NonRecoverableError

The compiler warns you. You fix it before running the code.

3. Can’t set the category wrong

1
2
3
4
final case class RateLimitError(...) extends LLMError with NonRecoverableError
// Later: someone reviews the PR
// "Wait, rate limit errors ARE recoverable. Fix this."
// The type is visible in the class declaration, easy to review

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
final case class ContentFilterError(...) extends LLMError {
  val isRecoverable = 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
final case class ContentFilterError(...) extends LLMError with NonRecoverableError

// The type system prevents retries
error match {
  case e: ContentFilterError =>
    // Specific handling: explain to user what was filtered
    return Left(e)  // Don't retry

  case e: RecoverableError =>
    retryWithBackoff()  // ContentFilterError doesn't match here!

  case e: NonRecoverableError =>
    return Left(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:

 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
error match {
  // Most specific: match exact error type
  case e: RateLimitError =>
    val waitTime = e.retryAfter.getOrElse(60.seconds)
    logger.info(s"Rate limited, waiting ${waitTime.toSeconds}s")
    Thread.sleep(waitTime.toMillis)
    retry()

  case e: TimeoutError =>
    // Different retry strategy for timeouts
    logger.warn(s"Timeout after ${e.timeout.toSeconds}s, retrying immediately")
    retry()

  case e: 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
  case e: RecoverableError =>
    // Catch-all for other recoverable errors we didn't handle above
    logger.info(s"Recoverable error: ${e.formatted}")
    retry()

  case e: 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") {
  val error = RateLimitError("Rate limited", "openai", None, "/v1/chat")
  error shouldBe a[RecoverableError]
}

test("AuthenticationError should be non-recoverable") {
  val error = AuthenticationError("Invalid API key", "openai", None)
  error shouldBe a[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:

1. ProcessingError (when internal processing fails)

 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 ProcessingError private (
  message: String,
  operation: String,
  input: Option[String],
  cause: Option[Throwable],
  context: Map[String, String]
) extends LLMError with NonRecoverableError {
  def formatted: String = {
    val inputSnippet = input.map(i => s"\nInput: ${i.take(200)}...").getOrElse("")
    val causeMsg = cause.map(t => s"\nCause: ${t.getMessage}").getOrElse("")
    s"[ProcessingError] $message during operation: $operation$inputSnippet$causeMsg"
  }
}

object ProcessingError {
  def apply(
    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")

    new ProcessingError(
      message = message,
      operation = operation,
      input = input,
      cause = cause,
      context = Map(
        "timestamp" -> Instant.now().toString,
        "operation" -> operation
      )
    )
  }
}

2. InvalidInputError (when user input is malformed)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
final case class InvalidInputError private (
  message: String,
  field: String,
  value: String,
  expected: Option[String],
  context: Map[String, String]
) extends LLMError with NonRecoverableError {
  def formatted: String = {
    val expectation = expected.map(e => s" Expected: $e.").getOrElse("")
    s"[InvalidInputError] $message\nField: $field\nValue: ${value.take(100)}$expectation"
  }
}

3. APIError (when provider API returns unexpected response)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
final case class APIError private (
  message: String,
  statusCode: Int,
  provider: String,
  responseBody: Option[String],
  context: Map[String, String]
) extends LLMError with NonRecoverableError {
  def formatted: String = {
    val body = responseBody.map(b => s"\nResponse: ${b.take(500)}").getOrElse("")
    s"[APIError] $message (provider: $provider, status: $statusCode)$body"
  }
}

Each one follows the same pattern:

  • Private constructor
  • Smart constructor with validation
  • Rich context
  • Formatted output
  • Proper categorization (recoverable vs non-recoverable)

The refactor in numbers

Here’s what changed in PR #197 :

Code metrics:

  • +638 lines added
  • -263 lines removed
  • +375 net lines (but simpler code!)
  • 25 files changed

Key changes:

1. LLMError.scala simplified

  • Before: 178 lines (complex inheritance)
  • After: 52 lines (clean sealed trait)
  • Reduction: 126 lines (71% simpler)

2. New error types added

  • ProcessingError.scala: 23 lines
  • InvalidInputError.scala: 27 lines
  • APIError.scala: 30 lines

3. Smart constructors for all errors

  • Each error got validation
  • Each error got context auto-population
  • Each error got convenience constructors

4. Provider updates

  • AnthropicClient: 5 lines changed (error handling)
  • OpenRouterClient: 4 lines changed (error handling)
  • Both now use new error types properly

5. Test coverage explosion

  • New file: LLMErrorSpec.scala (201 lines)
  • Test scenarios: ~25 test cases
  • Coverage: All error types tested

6. Trait hierarchy refined

  • RecoverableError: 6 lines
  • NonRecoverableError: 6 lines
  • Clear categorization enforced by types

The test suite

Testing strategy
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.

Testing smart constructors:

 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
54
55
56
57
58
59
60
61
class LLMErrorSpec extends AnyFunSuite with Matchers {
  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") {
    val error = RateLimitError(
      message = "OpenAI rate limit exceeded",
      provider = "openai",
      retryAfter = Some(60.seconds),
      endpoint = "/v1/completions"
    )

    error.message shouldBe "OpenAI rate limit exceeded"
    error.provider shouldBe "openai"
    error.retryAfter shouldBe Some(60.seconds)
    error.endpoint shouldBe "/v1/completions"
    error.context should contain key "timestamp"
    error.context should contain key "thread"
  }

  test("RateLimitError: should auto-populate context") {
    val error = RateLimitError(
      message = "Rate limited",
      provider = "openai",
      endpoint = "/v1/completions"
    )

    error.context should contain key "timestamp"
    error.context should contain key "thread"
    error.context should contain key "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") {
  val rateLimitError = RateLimitError("msg", "openai", None, "/v1/chat")
  val timeoutError = TimeoutError("msg", "openai", 30.seconds)
  val networkError = NetworkError("msg", "openai", None)

  rateLimitError shouldBe a[RecoverableError]
  timeoutError shouldBe a[RecoverableError]
  networkError shouldBe a[RecoverableError]
}

test("NonRecoverableError: should include expected error types") {
  val authError = AuthenticationError("msg", "openai", None)
  val validationError = ValidationError("msg", "input", None)
  val processingError = ProcessingError("msg", "parse", None, None)

  authError shouldBe a[NonRecoverableError]
  validationError shouldBe a[NonRecoverableError]
  processingError shouldBe a[NonRecoverableError]
}

Testing formatted output:

 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
test("RateLimitError: formatted should include all context") {
  val error = RateLimitError(
    message = "OpenAI rate limit exceeded",
    provider = "openai",
    retryAfter = Some(60.seconds),
    endpoint = "/v1/completions"
  )

  val formatted = error.formatted

  formatted should include("RateLimitError")
  formatted should include("OpenAI rate limit exceeded")
  formatted should include("provider: openai")
  formatted should include("endpoint: /v1/completions")
  formatted should include("Retry after 60s")
}

test("AuthenticationError: formatted should mask API key") {
  val error = AuthenticationError(
    message = "Invalid API key",
    provider = "openai",
    apiKeyPrefix = Some("sk-proj-12345")
  )

  val formatted = error.formatted

  formatted should include("AuthenticationError")
  formatted should include("Invalid API key")
  formatted should include("sk-proj-12345")
  formatted should not include "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.

Rory rebased it. Here’s what he did:

1. Pulled latest main

1
2
git checkout main
git pull origin main

2. Created new branch from my work

1
2
git checkout error-hierarchy-refinement-180
git checkout -b error-hierarchy-refinement-197

3. Interactive rebase

1
git rebase -i main

4. Resolved conflicts file by file

  • Kept my smart constructors
  • Integrated changes from other merged PRs
  • Fixed import paths that changed
  • Updated tests for new API

5. Verified everything

1
2
sbt compile
sbt test  # 119 tests passing

6. Opened PR #197

24 hours. Zero drama. Rory handled it because he’s done this 100 times.

That’s mentorship. Not just code reviews. Actual help when you’re stuck.

What we learned from code reviews

ReviewerFeedbackWhat changedImpact
Atul S. Khot“Boolean flags are code smells”Replaced isRecoverable: Boolean with marker traitsCompiler enforces categorization, exhaustive pattern matching
Atul S. Khot“Use smart constructors for validation”Made constructors private, added validated apply methodsInvalid errors impossible to create, fail fast during development
Atul S. Khot“Context maps should be consistent”Auto-populate timestamp, thread, error type in smart constructorsEvery error has full context, no manual logging needed
Atul S. Khot“Convenience constructors reduce boilerplate”Added fromResponse, fromThrowable helpersOne-line error creation instead of 10-line manual construction
Rory GravesHandled merge conflicts (15 commits behind main)Rebased PR #180 → PR #197, resolved conflicts file by fileUnblocked progress, enabled collaboration
Rory GravesReviewed smart constructor validation logicCaught edge cases in retry duration validationPrevented negative durations from slipping through
Rory GravesSuggested better test structureOrganized tests by error type, added exhaustiveness tests201-line test suite covering all validations

Better abstractions = less code

Here’s the counter-intuitive result:

We added 638 lines. We removed 263 lines. Net: +375 lines.

But the code got simpler. How?

Before: 178 lines in LLMError.scala with complex inheritance After: 52 lines in LLMError.scala with clean sealed trait

Before: Error creation scattered across providers After: Smart constructors centralize validation

Before: No tests for error creation After: 201 lines of tests covering all cases

We added structure. The structure made the code easier to understand, modify, and extend.

That’s good design. Not fewer lines. Better abstractions.

Impact on other work: how error refinement enabled various assignments

This refinement became the foundation for all contributors’ work. Here’s how smart constructors and marker traits helped each contributor.

Shubham Vishwakarma worked on tracing and observability work:

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
def logError(error: LLMError, trace: Trace): Unit = {
  // Every error has consistent context thanks to smart constructors
  val errorEvent = 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 Awasthi worked on multimodal support:

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.

Smart constructors made validation clean:

 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
// Anshuman's image error types
final case class InvalidImageDimensionsError private (
  message: String,
  requested: (Int, Int),
  supported: List[(Int, Int)],
  provider: String,
  context: Map[String, String]
) extends LLMError with NonRecoverableError {
  def formatted: String = {
    val supportedStr = supported.map { case (w, h) => s"${w}x${h}" }.mkString(", ")
    val (w, h) = requested
    s"[InvalidImageDimensionsError] ${w}x${h} not supported by $provider. Use one of: $supportedStr"
  }
}

object InvalidImageDimensionsError {
  def apply(
    requested: (Int, Int),
    supported: List[(Int, Int)],
    provider: String
  ): InvalidImageDimensionsError = {
    val (width, height) = requested
    require(width > 0 && height > 0, "Dimensions must be positive")
    require(supported.nonEmpty, "Supported dimensions list cannot be empty")

    new InvalidImageDimensionsError(
      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:

1
2
3
4
5
6
Left(InvalidImageDimensionsError(
  requested = (640, 480),
  supported = List((256, 256), (512, 512), (1024, 1024)),
  provider = "openai"
))
// Error message: "640x480 not supported by openai. Use one of: 256x256, 512x512, 1024x1024"

Clear. Actionable. The user knows exactly what to fix. This wouldn’t be possible with generic error messages.

Elvan Konukseven work on agent framework:

Elvan built an agent system that can retry failed operations, delegate to different tools, and recover from errors intelligently.

Marker traits made retry logic elegant:

 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
// Elvan's agent retry logic
def executeWithRetry[A](
  operation: => Result[A],
  maxRetries: Int = 3
): Result[A] = {
  @tailrec
  def attempt(retriesLeft: Int): Result[A] = {
    operation match {
      case Right(value) => Right(value)

      case Left(error: RecoverableError) if retriesLeft > 0 =>
        // Type-based decision: retry recoverable errors
        error match {
          case e: RateLimitError =>
            // Wait for retry-after duration
            val waitTime = e.retryAfter.getOrElse(60.seconds)
            Thread.sleep(waitTime.toMillis)

          case e: TimeoutError =>
            // Exponential backoff for timeouts
            val backoff = (4 - retriesLeft) * 2  // 2s, 4s, 6s
            Thread.sleep(backoff * 1000)

          case _ =>
            // Other recoverable errors: wait 1 second
            Thread.sleep(1000)
        }
        attempt(retriesLeft - 1)

      case Left(error: NonRecoverableError) =>
        // Type-based decision: don't retry non-recoverable errors
        Left(error)

      case Left(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 Trinadh Maddikunta ’s RAG pipeline:

Gopi worked to build a Retrieval-Augmented Generation system: vector embeddings for documents, similarity search, context injection into prompts.

RAG has many failure points:

  • Embedding generation can fail
  • Vector search can timeout
  • Context size can exceed limits
  • Documents can be malformed

Smart constructors made each failure clear:

 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
// Gopi's RAG error types
final case class EmbeddingGenerationError private (
  message: String,
  document: String,
  provider: String,
  cause: Option[Throwable],
  context: Map[String, String]
) extends LLMError with RecoverableError {
  def formatted: String = {
    val docSnippet = document.take(100)
    val causeMsg = cause.map(t => s" Cause: ${t.getMessage}").getOrElse("")
    s"[EmbeddingGenerationError] Failed to generate embedding for document: $docSnippet...$causeMsg"
  }
}

final case class ContextSizeExceededError private (
  message: String,
  requestedTokens: Int,
  maxTokens: Int,
  provider: String,
  context: Map[String, String]
) extends LLMError with NonRecoverableError {
  def formatted: 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.

Try it yourself

The code is in github.com/llm4s/llm4s .

PR #197 : Smart constructors and error refinement

Error hierarchy: src/main/scala/org/llm4s/error/

Tests: src/test/scala/org/llm4s/error/LLMErrorSpec.scala

Want to use llm4s ?

1
2
3
4
sbt new llm4s/llm4s.g8
cd my-llm-app
export OPENAI_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.


Series Navigation: ← Previous: Developer experience with llm4s.g8 | Next: Type system upgrades →

This is part 3 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 "Functional Programming: Where side effects go to die (and your sanity follows)"