Error Handling
λÆS approaches error handling functionally: errors are typed, explicit in function signatures, and handled without throwing exceptions. This step covers the Raise effect for typed errors and the Retry handler for resilient retry strategies.
Raise Effect
Section titled “Raise Effect”The Raise[E] effect describes the possibility that a function can raise an error of type E. It provides typed error handling inspired by the raise4s library.
Basic Usage
Section titled “Basic Usage”With Exception Types:
import in.rcard.yaes.Raise.*
def divide(a: Int, b: Int)(using Raise[ArithmeticException]): Int = if (b == 0) Raise.raise(new ArithmeticException("Division by zero")) else a / bWith Custom Error Types:
import in.rcard.yaes.Raise.*
object DivisionByZerotype DivisionByZero = DivisionByZero.type
def divide(a: Int, b: Int)(using Raise[DivisionByZero]): Int = if (b == 0) Raise.raise(DivisionByZero) else a / bUsing the raises Infix Type:
For more concise syntax, use the raises infix type instead of using Raise[E]:
import in.rcard.yaes.Raise.*
def divide(a: Int, b: Int): Int raises DivisionByZero = if (b == 0) Raise.raise(DivisionByZero) else a / b
val result: Int | DivisionByZero = Raise.run { divide(10, 0)}Utility Functions
Section titled “Utility Functions”Ensuring Conditions:
import in.rcard.yaes.Raise.*
def divide(a: Int, b: Int)(using Raise[DivisionByZero]): Int = { Raise.ensure(b != 0) { DivisionByZero } a / b}Ensuring Non-Null Values:
import in.rcard.yaes.Raise.*
object NullErrortype NullError = NullError.type
def processName(name: String | Null)(using Raise[NullError]): String = { val validName = Raise.ensureNotNull(name) { NullError } validName.toUpperCase}
val result = Raise.either { processName(null) }// result will be Left(NullError)Accumulating Errors:
Use accumulate and accumulating to collect multiple errors instead of short-circuiting on the first one:
import in.rcard.yaes.Raise.*
def validateName(name: String)(using Raise[String]): String = if (name.nonEmpty) name else Raise.raise("Name cannot be empty")
def validateAge(age: Int)(using Raise[String]): Int = if (age >= 0) age else Raise.raise("Age cannot be negative")
val result = Raise.either { Raise.accumulate { val name = accumulating { validateName("") } val age = accumulating { validateAge(-1) } (name, age) }}// result will be Left(List("Name cannot be empty", "Age cannot be negative"))mapAccumulating — Transform Collections While Collecting Errors:
import in.rcard.yaes.Raise.*
def validateNumber(n: Int)(using Raise[String]): Int = if (n > 0) n else Raise.raise(s"$n is not positive")
val result = Raise.either { Raise.mapAccumulating(List(1, -2, 3, -4, 5)) { number => validateNumber(number) }}// result will be Left(List("-2 is not positive", "-4 is not positive"))For complex error types, provide a custom error combination function:
import in.rcard.yaes.Raise.*
case class ValidationErrors(errors: List[String])
def combineErrors(e1: ValidationErrors, e2: ValidationErrors): ValidationErrors = ValidationErrors(e1.errors ++ e2.errors)
def validateUserData(data: String)(using Raise[ValidationErrors]): String = if (data.isEmpty) Raise.raise(ValidationErrors(List("Data cannot be empty"))) else if (data.length < 3) Raise.raise(ValidationErrors(List("Data too short"))) else data
val result = Raise.either { Raise.mapAccumulating(List("Alice", "", "Bo", "Charlie"), combineErrors) { userData => validateUserData(userData) }}// result will be Left(ValidationErrors(List("Data cannot be empty", "Data too short")))Polymorphic Error Accumulation (requires yaes-cats):
import in.rcard.yaes.{Raise, RaiseNel}import in.rcard.yaes.Raise.accumulatingimport in.rcard.yaes.instances.accumulate.givenimport cats.data.NonEmptyList
def validatePositive(n: Int)(using Raise[String]): Int = if (n > 0) n else Raise.raise(s"$n is not positive")
val result: Either[NonEmptyList[String], (Int, Int)] = Raise.either { Raise.accumulate[NonEmptyList, String, (Int, Int)] { val a = accumulating { validatePositive(-1) } val b = accumulating { validatePositive(-2) } (a, b) }}// result: Left(NonEmptyList("-1 is not positive", List("-2 is not positive")))Available collector types:
List: Built-in (always available)NonEmptyList(RaiseNel[E]): Requiresyaes-catsNonEmptyChain(RaiseNec[E]): Requiresyaes-cats
Transforming Error Types:
import in.rcard.yaes.Raise.*
sealed trait NetworkErrorcase object ConnectionTimeout extends NetworkErrorcase object InvalidResponse extends NetworkError
sealed trait ServiceErrorcase object ServiceUnavailable extends ServiceErrorcase object InvalidData extends ServiceError
def fetchData(url: String)(using Raise[NetworkError]): String = if (url.isEmpty) Raise.raise(InvalidResponse) else "data"
def processData(url: String)(using Raise[ServiceError]): String = { Raise.withError[ServiceError, NetworkError, String] { case ConnectionTimeout => ServiceUnavailable case InvalidResponse => InvalidData } { fetchData(url) }}
val result = Raise.either { processData("") // Raises InvalidResponse, transformed to InvalidData}// result will be Left(InvalidData)Catching Exceptions:
Transform exceptions into typed errors:
import in.rcard.yaes.Raise.*
def divide(a: Int, b: Int)(using Raise[DivisionByZero]): Int = Raise.catching[ArithmeticException] { a / b } { _ => DivisionByZero }Handlers
Section titled “Handlers”Union Type Handler:
val result: Int | DivisionByZero = Raise.run { divide(10, 0)}Either Handler:
val result: Either[DivisionByZero, Int] = Raise.either { divide(10, 0)}Option Handler — requires the block to raise None explicitly:
def safeDivide(x: Int, y: Int)(using Raise[None.type]): Int = if (y == 0) then Raise.raise(None) else x / y
val result: Option[Int] = Raise.option { safeDivide(10, 0)}// result will be NoneNullable Handler — requires the block to raise null explicitly:
def safeDivide(x: Int, y: Int)(using Raise[Null]): Int = if (y == 0) then Raise.raise(null) else x / y
val result: Int | Null = Raise.nullable { safeDivide(10, 0)}// result will be nullError Tracing
Section titled “Error Tracing”The traced function adds tracing capabilities to error handling, capturing stack traces when errors occur:
import in.rcard.yaes.Raise.*
given TraceWith[String] = trace => { println(s"Error occurred: ${trace.original}") trace.printStackTrace()}
def riskyOperation(value: Int)(using Raise[String]): Int = if (value < 0) Raise.raise("Negative value not allowed") else value * 2
val result = Raise.either { traced { riskyOperation(-5) }}// Prints error details and stack trace, then returns Left("Negative value not allowed")Default Tracing:
import in.rcard.yaes.Raise.*import in.rcard.yaes.Raise.given // Import default tracing
val result = Raise.either { traced { Raise.raise("Something went wrong") }}// Automatically prints stack trace, then returns Left("Something went wrong")Error Composition
Section titled “Error Composition”Combine multiple error types in a single function:
import in.rcard.yaes.Raise.*
sealed trait ValidationErrorcase object InvalidEmail extends ValidationErrorcase object InvalidAge extends ValidationError
def validateUser(email: String, age: Int)(using Raise[ValidationError]): User = { val validEmail = if (email.contains("@")) email else Raise.raise(InvalidEmail) val validAge = if (age >= 0) age else Raise.raise(InvalidAge) User(validEmail, validAge)}Best Practices
Section titled “Best Practices”- Use specific error types rather than generic exceptions
- Combine with other effects like
Syncfor comprehensive error handling - Handle errors at appropriate boundaries in your application
- Use union types for simple error handling,
Eitherfor more complex scenarios
Retry Handler
Section titled “Retry Handler”The Retry handler re-executes a failing block according to a Schedule retry policy. It catches typed errors via Raise[E] and uses Async for delays between attempts.
Basic Usage
Section titled “Basic Usage”import in.rcard.yaes.Async.*import in.rcard.yaes.Raise.*import scala.concurrent.duration.*
case class DbError(msg: String)
def findUser(id: Int)(using Raise[DbError]): String = Raise.raise(DbError("connection timeout"))
val result: Either[DbError, String] = Async.run { Raise.either { Retry[DbError](Schedule.fixed(500.millis).attempts(3)) { findUser(42) } }}// result will be Left(DbError("connection timeout")) after 3 total attemptsIf the block succeeds on any attempt, its value is returned immediately. If all attempts are exhausted, the last error is re-raised via the outer Raise[E].
Schedule Policies
Section titled “Schedule Policies”A Schedule computes Option[Duration] for each retry attempt. Attempts are 1-indexed: attempt 1 is the first retry after the initial failure.
Fixed Delay — constant delay between each retry:
val schedule = Schedule.fixed(500.millis)schedule.delay(1) // Some(500.millis)schedule.delay(100) // Some(500.millis)Exponential Backoff — delay grows as initial * factor^(attempt-1), optionally capped:
val schedule = Schedule.exponential(100.millis, factor = 2.0, max = 5.seconds)schedule.delay(1) // Some(100.millis)schedule.delay(2) // Some(200.millis)schedule.delay(3) // Some(400.millis)schedule.delay(4) // Some(800.millis)Parameters:
initial— delay before the first retryfactor— multiplier per attempt (default2.0)max— maximum delay cap (defaultDuration.Inf, meaning no cap)
Limiting Attempts:
The attempts extension limits the total number of executions (1 initial + N-1 retries). attempts(0) and attempts(1) both result in no retries:
val schedule = Schedule.fixed(100.millis).attempts(3)schedule.delay(1) // Some(100.millis) — 1st retryschedule.delay(2) // Some(100.millis) — 2nd retryschedule.delay(3) // None — stop (3 total executions reached)Adding Jitter — prevents thundering herd problems:
// jitter requires the Random effect in scopeval schedule = Random.run { Schedule.fixed(1.second).jitter(0.5)}// Each delay will be random in [500ms, 1500ms]A factor of 0.5 on a 1-second delay produces delays in [500ms, 1500ms].
Composing Schedules
Section titled “Composing Schedules”Schedule extensions compose naturally via chaining:
// Exponential backoff with jitter, capped at 30s, up to 5 total attemptsval schedule = Random.run { Schedule .exponential(100.millis, factor = 2.0, max = 30.seconds) .jitter(0.25) .attempts(5)}Practical Examples
Section titled “Practical Examples”HTTP Client with Retry:
import in.rcard.yaes.Async.*import in.rcard.yaes.Raise.*import in.rcard.yaes.Random.*import scala.concurrent.duration.*
sealed trait HttpErrorcase class Timeout(msg: String) extends HttpErrorcase class ServerError(code: Int) extends HttpError
def fetchData(url: String)(using Raise[HttpError], Async): String = ???
val result: Either[HttpError, String] = Random.run { Async.run { Raise.either { Retry[HttpError]( Schedule.exponential(100.millis, factor = 2.0, max = 5.seconds) .jitter(0.5) .attempts(5) ) { fetchData("https://api.example.com/data") } } }}Retrying Only Specific Errors:
Retry retries all errors of the specified type E. If your block raises multiple error types, only the type parameter of Retry is intercepted — other error types propagate immediately:
val result: Either[String, Either[Int, Int]] = Async.run { Raise.either[String, Either[Int, Int]] { Raise.either[Int, Int] { Retry[Int](Schedule.fixed(10.millis).attempts(5)) { // Int errors are retried // String errors propagate immediately through the outer Raise Raise.raise("fatal error") 42 } } }}// result is Left("fatal error") — no retries occurred