General usage

rezilience policies are created as Scoped effects. This allows them to run background operations which are cleaned up safely after usage. Since these scoped effects are just descriptions of the policy, they can be passed around to various call sites and used to create many instances.

All instantiated policies are defined as traits with an apply method that takes a ZIO effect as parameter:

trait Retry {
  def apply[R, E, A](f: ZIO[R, E, A]): ZIO[R, E, A]
}

Therefore a policy can be used as if it were a function taking a ZIO effect, eg:

ZIO.scoped {
  Retry.make(...).flatMap { retry =>
    retry(callToExternalSystem) // shorthand for retry.apply(callToExternalSystem) 
  }
}

Policies can be applied to any type of ZIO[R, E, A] effect, although some policies have an upper bound for E depending on how they are created. Some policies alter the return error type, others leave it as is:

Policy Error type upper bound Result type
CircuitBreaker Any, or E (when isFailure parameter is used) ZIO[R, CircuitBreakerError[E], A]
RateLimiter Any ZIO[R, E, A]
Bulkhead Any ZIO[R, BulkheadError[E], A]
Retry Any, or E when a Schedule[Env, E, Out] is used ZIO[R, E, A]
Timeout Any ZIO[R, TimeoutError[E], A]

Mapping errors

rezilience policies are type-safe in the error channel, which means that some of them change the error type of the effects they are applied to (see table above). For example, applying a CircuitBreaker to an effect of type ZIO[Any, Throwable, Unit] will result in a ZIO[Any, CircuitBreakerError[Throwable], Unit].

This CircuitBreakerError has two subtypes:

  • case object CircuitBreakerOpen: the error when the circuit breaker has tripped and no attempt to make the call has been made
  • case class WrappedError[E](error: E): the error coming from the call

By having this datatype for errors, rezilience requires you to be explicit in how you want to handle circuit breaker errors, in line with the rest of ZIO’s strategy for typed error handling. At a higher level in your application you may want to inform the user that a system is temporarily not available or execute some fallback logic.

Several conveniences are available for dealing with circuit breaker errors:

  • CircuitBreakerError#fold[O](circuitBreakerOpen: O, unwrap: E => O)
    Convert a CircuitBreakerOpen or a WrappedError into an O.
  • CircuitBreakerError#toException
    Converts a CircuitBreakerError to a CircuitBreakerException.

For example:

sealed trait MyServiceErrorType
case object SystemNotInTheMood extends MyServiceErrorType
case object UnknownServiceError extends MyServiceErrorType

def callExternalSystem(someInput: String): ZIO[Any, MyServiceErrorType, Int] = 
  ZIO.succeed(someInput.length)

val result1: ZIO[Any, CircuitBreakerError[MyServiceErrorType], Int] = 
  circuitBreaker(callExternalSystem("1234"))

// Map the CircuitBreakerError back onto an UnknownServiceError
val result2: ZIO[Any, MyServiceErrorType, Int] = 
  result1.mapError(policyError => policyError.fold(UnknownServiceError, identity(_)))

// Or turn it into an exception
val result3: ZIO[Any, Throwable, Int] =
  result1.mapError(policyError => policyError.toException)

Similar methods exist on BulkheadError and PolicyError (see Bulkhead and Combining Policies)

ZLayer integration

You can apply rezilience policies at the level of an individual ZIO effect. But having to wrap all your calls in eg a rate limiter can clutter your code somewhat. When you are using the ZIO module pattern using ZLayer, it is also possible to integrate a rezilience policy with some service at the ZLayer level. In the spirit of aspect oriented programming, the code using your service will not be cluttered with the aspect of rate limiting.

For example:

val addRateLimiterToDatabase: ZLayer[Database, Nothing, Database] = {
  ZLayer.scoped {
    ZLayer.fromService { database: Database.Service =>
      RateLimiter.make(10).map { rateLimiter =>
        new Database.Service {
          override def transfer(amount: Amount, from: Account, to: Account): ZIO[Any, Throwable, Unit] =
            rateLimiter(database.transfer(amount, from, to))
        }
      }
    }
  }
}

For policies where the result type has a different E you will need to map the error back to your own E. An option is to have something like a general case class UnknownServiceError(e: Exception) in your service error type, to which you can map the policy errors. If that is not possible for some reason, you can also define a new service type like ResilientDatabase where the error types are PolicyError[E].

See the full example for more.