Error Handling with Result
How to work with Result[A] and LLMError in practice.
Table of contents
- 1. What is
Result[A]? - 2. The basic pattern
- 3. Chaining with for-comprehensions
- 4. Error types and when each is raised
- 5. Handling specific error types
- 6. Converting to exceptions (when you must)
- 7. Turning exceptions into errors
- 8. Combining results
- 9. Recovering from failures
- 10. Testing code that returns Result
- Rules of thumb
- See also
LLM4S does not throw exceptions for failures it expects: a missing API key, a rate limit, a timeout
and a rejected request all come back as values. If you know Java or Python, this replaces
try/catch with something the compiler checks. This page shows how to use it day to day.
Every snippet after section 1 (which only shows definitions) is compiled and run by
ErrorHandlingGuideSpec,
so the names and calls in it match the current API, and each snippet shows the imports it needs. If you
change one, change the other.
1. What is Result[A]?
1
type Result[+A] = Either[LLMError, A]
A call either succeeds with a Right(value) or fails with a Left(error), where error is an
LLMError. There is no third outcome: nothing is thrown past you, and a null is never returned in
place of an error.
Two imports come up. The type alias is in org.llm4s.types; the helper object (Result.success,
Result.traverse and friends, used in section 8) is org.llm4s.Result:
1
2
import org.llm4s.Result // the helper object
import org.llm4s.types.{ Result, TryOps } // the type alias, and Try-to-Result syntax
2. The basic pattern
Match on the result. Right holds the value and Left holds the error:
1
2
3
4
5
6
7
8
import org.llm4s.llmconnect.model.Completion
import org.llm4s.types.Result
def basicPattern(result: Result[Completion]): String =
result match {
case Right(completion) => s"Response: ${completion.content}"
case Left(error) => s"Error: ${error.message}"
}
A completion’s text is completion.content. error.message is for people;
error.formatted adds the error’s type, code and context, which is what to log.
3. Chaining with for-comprehensions
Most programs make several calls that can each fail. A for comprehension runs them in order and
stops at the first Left, which becomes the result. The calls after it do not run:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import org.llm4s.config.Llm4sConfig
import org.llm4s.llmconnect.LLMConnect
import org.llm4s.llmconnect.model.Conversation
import org.llm4s.model.ModelRegistryService
import org.llm4s.types.Result
def chained(): Result[String] =
for {
providerConfig <- Llm4sConfig.defaultProvider()
registry <- Llm4sConfig.modelRegistryService()
given ModelRegistryService = registry
client <- LLMConnect.getClient(providerConfig)
conversation <- Conversation.userOnly("What is Scala?")
completion <- client.complete(conversation)
} yield completion.content
Loading the configuration, building the client, building the conversation (which validates it) and
making the call are four steps that can each fail, and you handle them once, at the end.
Llm4sConfig.defaultProvider() reads the section llm4s.providers.provider names; see
Configuration.
4. Error types and when each is raised
Every error is an LLMError, carrying a message, an optional code and a context map. The types in
org.llm4s.error are each also marked as one of two kinds:
- Recoverable (
RecoverableError): the same call may succeed if tried again. - Non-recoverable (
NonRecoverableError): trying again will not help; something has to change.
LLMError.isRecoverable(error) tells you which, for an error that carries a marker. Some errors from
other modules carry neither; see errors defined by other modules.
| Error | Recoverable | Where the library raises it |
|---|---|---|
AuthenticationError |
no | A provider rejects the credentials: HTTP 401 or 403, Anthropic’s UnauthorizedException, Bedrock’s access-denied or missing-credentials exceptions, Gemini’s invalid-key 400. Also when Vertex AI or watsonx cannot obtain a token, and from DefaultErrorMapper for an exception whose message mentions 401. |
ConfigurationError |
no | Configuration is missing or invalid: no provider section, no API key, a provider id that no module on the classpath registers, an embedding model with no known dimensions, or a block a config loader cannot read (providers, embeddings, tracing, metrics, tools, RAG, speech). |
ValidationError |
no | A request or value is rejected: an invalid message or conversation, a model the model registry does not know, a guardrail rejecting input or output (the built-in guardrails reject with one), HTTP 400 (on field request), Anthropic’s or Bedrock’s invalid-request exceptions, or a response body that cannot be parsed. |
InvalidInputError |
no | Only llm4s-image’s image processing (LocalImageProcessor, saving an image): an unreadable path, a bad resize or crop, a path traversal. It carries the field, the value and the reason. |
RateLimitError |
yes | A provider’s rate limit: HTTP 429, or Anthropic’s or Bedrock’s throttling exception. Also ReliableClient’s own limiter, and DefaultErrorMapper for an exception whose message mentions 429. It carries retryAfter when the provider says how long to wait. |
ServiceError |
yes | Any other non-2xx status from a provider (through HttpErrorMapper or Llm4sHttpClient, and from Bedrock and watsonx), or a call rejected by an open circuit breaker (ReliableClient or ErrorRecovery.CircuitBreaker, status 503). It carries httpStatus; see the note below. |
NetworkError |
yes | A connection fails, a host is unknown or I/O breaks, in llm4s’s HTTP client or the Bedrock client; DefaultErrorMapper, which the OpenAI and Anthropic clients use for I/O failures, gives one for a socket timeout or a refused connection. Also a URL refused by the SSRF check, and a failed llm4s-rag URL, web-crawl or S3 load, including a non-2xx answer to UrlLoader. |
TimeoutError |
yes | A connect, request or socket timeout elapses in llm4s’s own HTTP client, or a ReliableClient deadline passes. The vendor-SDK clients map timeouts themselves: OpenAI and Bedrock report a NetworkError, and Anthropic maps its exceptions through DefaultErrorMapper. |
APIError |
yes | Only llm4s-image’s vision clients (OpenAI, Anthropic, Gemini), through LLMError.apiCallFailed, when the vision API call fails or returns no text; it carries the provider and, optionally, a status code. Image generation has its own errors (see below). |
ExecutionError |
yes | Only ErrorRecovery.recoverWithBackoff, when it runs out of attempts (section 9). Tool, MCP and orchestration failures are other types; see the note below. |
SystemError |
yes | Not raised by the library; available for your own code, for an unexpected failure that may be transient. |
OptimisticLockFailure |
yes | Only llm4s-memory-postgres’s PostgresMemoryStore, when another writer updated the same memory record first; re-read it and try again. |
CancelledError |
no | The thread was interrupted. Interruption is how llm4s cancels work, and a cancelled call is never retried. |
ProcessingError |
no | A storage, parsing or processing step fails: a vector-store, keyword-index or memory-store operation, RAG document loading, extraction or permissions, knowledge-graph storage, extraction or queries, speech audio processing, image encoding or saving. |
NotFoundError |
no | A memory store (in-memory, SQLite, Postgres) is asked to update a memory it does not hold, or VectorStoreFactory is given an unknown backend name. |
ContextError |
no | Context compression fails: LLMCompressor’s LLM call fails, or ToolOutputCompressor cannot store an artifact or parse JSON content. |
TokenizerError |
no | ConversationTokenCounter has no tokenizer for the requested id. |
SimpleError |
no | The MCP client and its transports (llm4s-mcp): connection, session, JSON-RPC and server-process failures. |
UnknownError |
no | An unexpected exception, wrapped: the fallback of DefaultErrorMapper (and so of toResult and toLLMError), an unexpected failure in llm4s’s HTTP client, or a tracing backend that fails to start or to export. |
The table lists the LLMError types in org.llm4s.error, the package that is the source of truth for this core
set. Other modules define errors of their own, below.
Same name, different type. A failed tool call is not an org.llm4s.error.ExecutionError.
ToolRegistry.execute, and the MCP tool registry, return a ToolCallError (org.llm4s.toolapi), whose
case for a tool that threw is ToolCallError.ExecutionError. ToolCallError is not an LLMError, and the
agent hands it back to the model as the tool’s result instead of failing the run. Orchestration fails with
OrchestrationError.NodeExecutionError or PlanExecutionError, and a graph’s tool loop with
GraphError.ToolFailed; both are described below.
ServiceError and its status. The marker says a ServiceError is recoverable, but a 404 is not
going to fix itself. When it matters, look at httpStatus: error.isRecoverableStatus (from
ServiceError.ServiceErrorOps) is true for 5xx, 429 and 408. recoverWithBackoff and
ReliableClient retry a ServiceError only when it is.
Which status becomes which error. The status mapping in the table is HttpErrorMapper’s: 401 and
403 to AuthenticationError, 429 to RateLimitError, 400 to ValidationError, any other non-2xx to
ServiceError. The OpenAI, Azure, Requesty, Gemini, Vertex AI, Ollama, watsonx and OpenAI-compatible
clients use it; Gemini first turns a 400 that reports an invalid API key into an AuthenticationError.
The Anthropic client maps its SDK’s exceptions instead: unauthorized (401) to AuthenticationError, rate
limited to RateLimitError (with no retryAfter), invalid data to a ValidationError on field input,
and anything else, including other HTTP statuses, through DefaultErrorMapper
(section 7), usually to an UnknownError. The Bedrock client maps the
AWS SDK’s exceptions: throttling or an exceeded quota to RateLimitError, an invalid request to
ValidationError, access denied, a 401 or 403, or missing credentials to AuthenticationError, any other
service status to ServiceError, and any other client failure to NetworkError.
Errors defined by other modules
Some modules add their own LLMError subtypes. These carry neither marker, so
LLMError.isRecoverable throws a MatchError on them today:
| Module | Package | Errors |
|---|---|---|
llm4s-core |
org.llm4s.llmconnect.model |
EmbeddingError: how EmbeddingClient.embed and the embedding providers (OpenAI, Ollama, Voyage, Cohere, Jina) report a failure other than cancellation, except that the Cohere provider reports a 429 as a RateLimitError |
llm4s-agent |
org.llm4s.agent.orchestration |
OrchestrationError: PlanValidationError, PlanExecutionError and TypeMismatchError from PlanRunner; NodeExecutionError from PlanRunner and TypedAgent (it has its own recoverable flag); AgentTimeoutError from Policies.withTimeout |
llm4s-rag |
org.llm4s.rag.evaluation, org.llm4s.reranker |
EvaluationError from RAGAS evaluation and the RAG benchmark tools; RerankError from the Cohere and LLM rerankers (Reranker.rerank) |
llm4s-speech |
org.llm4s.speech.tts, .stt, .io |
TTSError from the text-to-speech clients; STTError from the speech-to-text clients (it has its own retryable flag); WavFileGenerator.WavError and AudioIO.AudioIOError from generating and saving audio files |
These are marked, so isRecoverable works on them: GraphError in llm4s-agent
(org.llm4s.agent.graph, from the graph runtime; every case is non-recoverable except DeadlineExceeded)
and ImageGenerationError in llm4s-image (org.llm4s.imagegeneration, from the image-generation
clients; a ServiceError there is recoverable only for a transient status). The image module reuses the
names AuthenticationError, RateLimitError, ServiceError, ValidationError and UnknownError, so
import those by package instead of with a wildcard next to org.llm4s.error._. Its vision clients
(org.llm4s.imageprocessing) return the core errors in the table above.
isRecoverable should be made total in a later change. Until then, match on the marker trait, as the next
section does, which is safe for every error.
5. Handling specific error types
Match on the type to react differently to each failure. Put the specific cases first and finish with a catch-all:
1
2
3
4
5
6
7
8
9
10
11
12
import org.llm4s.error._
import org.llm4s.types.Result
def describe(result: Result[String]): String =
result match {
case Right(text) => s"ok: $text"
case Left(e: RateLimitError) =>
s"wait ${e.retryDelay.getOrElse(RateLimitError.DefaultRetryDelay)}, then retry"
case Left(e: AuthenticationError) => s"fix the credentials for ${e.provider}"
case Left(e: RecoverableError) => s"transient, may succeed on retry: ${e.message}"
case Left(e) => s"not retried (permanent, or not marked either way): ${e.message}"
}
RateLimitError.retryDelay is the delay the provider asked for, or 30 seconds when it did not say.
Two things to know:
LLMErroris not sealed, so the compiler cannot tell you that a match is complete. Always end with acase Left(e).- A custom error must say what kind it is. If you define your own error type, mix in
RecoverableErrororNonRecoverableErroras well asLLMError.LLMError.isRecoverablethrows aMatchErrorfor a type that is neither, as it does for the library errors listed above. Matching onRecoverableError, asdescribedoes, never throws.
1
2
3
import org.llm4s.error.{ LLMError, NonRecoverableError }
final case class VendorError(message: String) extends LLMError with NonRecoverableError
6. Converting to exceptions (when you must)
Sometimes a framework expects an exception: a test setup, a main that should crash on bad
configuration, an API you do not control. Convert at the edge, in one place, and keep the error’s
details:
1
2
3
4
import org.llm4s.types.Result
def orThrow[A](result: Result[A]): A =
result.fold(error => throw new RuntimeException(error.formatted), identity)
error.formatted carries the error’s type and context, so the stack trace says what went wrong.
Prefer fold to result.getOrElse(throw new RuntimeException("LLM call failed")). The second
compiles, but it discards the error, so the exception cannot say what went wrong. And do not use
result.getOrElse(default) unless a silent fallback is what you want: a failure becomes the default
with no trace.
Do not throw from library code you write: return a Result and let the caller decide.
7. Turning exceptions into errors
The other direction matters just as much, because the JDK and other libraries throw. Wrap them at
the boundary so the rest of your code only sees Result:
1
2
3
4
5
6
7
8
9
10
11
12
import org.llm4s.Result
import org.llm4s.error.NotFoundError
import org.llm4s.error.ThrowableOps._
import org.llm4s.types.{ OptionOps, TryOps }
import scala.util.Try
Try("123".toInt).toResult // Right(123)
Try("abc".toInt).toResult // Left(...), the exception mapped to an LLMError
Result.safely(1 / 0) // Left(...), a throwing block captured
new IllegalStateException("boom").toLLMError // a Throwable as an LLMError
Option.empty[String].toResult(NotFoundError("no such key", "model")) // Left(NotFoundError)
toResult and toLLMError map an exception through DefaultErrorMapper: an interrupt becomes a
CancelledError, a socket timeout or a refused connection a NetworkError, an exception whose message
mentions 401 or 429 an AuthenticationError or a RateLimitError, and anything else an UnknownError
that keeps the exception. Pass your own ErrorMapper for a finer mapping. Try (and so
Result.safely) never captures an InterruptedException, which Scala treats as fatal, so let an
interrupt propagate or map it yourself with toLLMError.
To make an error yourself, use the type’s smart constructor:
1
2
3
4
5
import org.llm4s.error.{ ConfigurationError, NotFoundError, ValidationError }
ValidationError("model", "must not be empty")
ConfigurationError("no provider configured", List("llm4s.providers.provider"))
NotFoundError("no such key", "model")
8. Combining results
When you have a list of things to do, Result has helpers so you do not write the loop:
1
2
3
4
5
6
7
8
9
10
import org.llm4s.Result
import org.llm4s.types.{ Result, TryOps }
import scala.util.Try
def parseAll(inputs: List[String]): Result[List[Int]] =
Result.traverse(inputs)(s => Try(s.toInt).toResult)
parseAll(List("1", "2", "3")) // Right(List(1, 2, 3))
parseAll(List("1", "x", "y")) // Left(...), the first failure
Result.traversestops at the first failure: it does not call the function on the elements after it, so side effects and expensive work stop there too.Result.sequencereturns the first failure in a list of results that have already been computed.Result.validateAll(items)(check)runs every check and returns all the failures as aLeft(List[LLMError]): use it when you want to report every problem at once.Result.combine(a, b)joins two independent results into a tuple.
9. Recovering from failures
A recoverable error is worth a retry. ErrorRecovery.recoverWithBackoff calls an operation up to
maxAttempts times in all, waiting between attempts. Here client is any LLMClient and conversation a Conversation, built as in
section 3:
1
2
3
4
5
6
7
8
9
import org.llm4s.error.ErrorRecovery
import scala.concurrent.duration._
val result = ErrorRecovery.recoverWithBackoff(
() => client.complete(conversation),
maxAttempts = 3,
baseDelay = 1.second
)
It retries three error types, each on its own schedule. The delays are not exponential:
| Error | Wait before the next attempt |
|---|---|
RateLimitError |
Its retryDelay: the provider’s retryAfter when it gave one, else 30 seconds (RateLimitError.DefaultRetryDelay). baseDelay is not used. |
ServiceError with a 5xx, 429 or 408 status |
The provider’s retryAfter when it gave one, else baseDelay times the number of the attempt that failed: baseDelay, then 2 * baseDelay, and so on. |
TimeoutError |
baseDelay, every time. |
Every other error comes back unchanged straight away, whichever attempt it happens on: a
ValidationError on the last attempt, after a retried timeout, is still a ValidationError. That
includes NetworkError and a ServiceError with any other status, which this function does not retry
(ReliableClient does retry a NetworkError). A CancelledError is never retried or wrapped, and an
interrupt during a wait returns one.
When the last attempt fails with one of the three retried types, you get an ExecutionError whose
message gives the number of attempts and the last error’s message, and whose operation is the last
error’s formatted text; the original error’s type is not kept. ExecutionError is itself a
RecoverableError, so an outer retry loop that matches on the marker will retry it. The operation
always runs at least once, even if maxAttempts is below 1.
To stop calling a service that keeps failing, wrap the call in an ErrorRecovery.CircuitBreaker.
After failureThreshold consecutive failures (a success resets the count) it opens, and further calls fail fast with a ServiceError
without reaching the service, until recoveryTimeout has passed and a single probe call is allowed.
For production use, ReliableClient wraps a client with retry (its own RetryPolicy, with
configurable backoff), a circuit breaker, and an optional deadline and rate limit, in one place. See Error Recovery for retry strategies, fallbacks and
graceful degradation.
10. Testing code that returns Result
Check the Right and the Left the same way you would any value. With ScalaTest’s EitherValues,
.value unwraps a Right and .left.value unwraps a Left, failing the test with a clear message
if it is the other one:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import org.llm4s.error.ValidationError
import org.llm4s.types.Result
import org.scalatest.EitherValues
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
class ResultSpec extends AnyWordSpec with Matchers with EitherValues {
"a Result" should {
"unwrap with EitherValues, and check the error type" in {
val ok: Result[String] = Right("expected")
val failed: Result[String] = Left(ValidationError("model", "empty"))
ok.value shouldBe "expected"
ok.map(_.toUpperCase) shouldBe Right("EXPECTED")
failed.left.value shouldBe a[ValidationError]
failed.left.value.message should include("empty")
}
}
}
To test code that calls an LLM without a network, give it a client that returns what you choose. See the Testing Guide.
Rules of thumb
- Return
Resultfrom your own functions; do not throw. - Convert exceptions to errors where they enter your code, and errors to exceptions only where a framework forces you to, in one place.
- Match on specific types first, then on
RecoverableError, then a catch-all. CallLLMError.isRecoverableonly on an error you know carries a marker. - Retry only what is recoverable, with a limit and a delay; never retry a
CancelledError. - Log
error.formatted, showerror.message, and never put an API key in either.
See also
- Basic Usage: your first calls and the
Resulttype - Error Recovery: retries, circuit breakers and fallbacks
- Configuration: named provider sections and API keys
- Testing Guide: testing with mock clients