Here’s a comprehensive guide to implementing retry logic in ZIO 2 applications, using various scheduling strategies.
Please note that I haven’t double-checked that all of these examples compile as-is, but I do demonstrate many of these in my free Scala and ZIO 2 training videos. (I also added a complete working example at the end.)
Basic Retry Patterns
1. Simple Exponential Backoff with Jitter
val retrySchedule = Schedule.exponential(20.milliseconds)
.jittered
Perfect for distributed systems where you want to avoid thundering herd problems.
2. Limited Number of Retries
val limitedRetries = Schedule.exponential(10.milliseconds)
.jittered && Schedule.recurs(3)
Best for operations that should fail fast after a few attempts.
3. Time-Boxed Retries
val timeBoxed = Schedule.exponential(10.milliseconds)
.jittered &&
Schedule.elapsed.whileOutput(duration =>
duration < 5.seconds
)
Useful when you need to enforce a strict timeout regardless of retry count.
Advanced Scheduling Strategies
4. Fibonacci Backoff
val fibonacciRetries = Schedule.fibonacci(10.milliseconds)
.jittered && Schedule.recurs(5)
Less aggressive than exponential backoff - good for gradual scaling.
5. Fixed Interval Retries
val fixedIntervalRetries = Schedule.spaced(100.milliseconds) &&
Schedule.recurs(5)
When you need consistent, predictable retry intervals.
6. Conditional Retry Control
Stop When Max Delay Reached
val untilMaxDelay = Schedule.exponential(10.milliseconds)
.jittered
.untilOutput(delay => delay >= 1.second)
Continue While Under Max Delay
val whileUnderMaxDelay = Schedule.exponential(10.milliseconds)
.jittered
.whileOutput(delay => delay < 1.second)
7. Combining Schedules
Using OR Logic
val eitherCondition =
(Schedule.exponential(10.ms).jittered && Schedule.recurs(5)) ||
(Schedule.exponential(10.ms).jittered &&
Schedule.elapsed.whileOutput(d => d < 5.seconds))
Retries stop when either condition is met.
Sequential Schedules
val sequentialSchedules =
(Schedule.spaced(10.milliseconds) && Schedule.recurs(3))
.andThen(Schedule.spaced(900.milliseconds) && Schedule.recurs(2))
Useful for implementing different retry strategies in sequence.
Monitoring and Debugging
8. Annotated Retries with Logging
val annotatedRetries = Schedule.exponential(10.milliseconds)
.jittered &&
Schedule.recurs(5)
.onDecision { (decision, input, output) =>
ZIO.logInfo(s"Retry decision: $decision") >>
ZIO.logInfo(s"Input: $input") >>
ZIO.logInfo(s"Output: $output")
}
9. Collecting Retry Statistics
val collectRetryDelays = Schedule.exponential(10.milliseconds)
.jittered
.collectAll
Usage Patterns
10. Using in for-Comprehensions
def retryOperation[E, A](effect: ZIO[Any, E, A]) = for {
result <- effect.retry(limitedRetries)
_ <- ZIO.logInfo("Operation completed")
} yield result
11. Pattern Matching on Results
def handleRetry[E, A](result: Exit[E, A]) = result match {
case Exit.Success(value) => ZIO.succeed(value)
case Exit.Failure(cause) => ZIO.logError(s"Failed with: $cause")
}
Best Practices
- Always Add Jitter: For distributed systems, always use
.jittered
to prevent synchronized retry storms. - Combine Limits: Use both time and attempt limits for robust retry policies.
- Log Retry Attempts: Include retry logging for debugging and monitoring.
- Consider Backoff Strategy: Choose between exponential, fibonacci, or fixed delays based on your use case.
- Handle Final Failures: Always include error handling for when retries are exhausted.
All In One Examples
While I’m in the neighborhood, here’s a large collection of ZIO 2 retry and scheduling examples that I have been experimenting with. I can confirm that all of these do compile.
object ZioRetrySchedulingExamples extends ZIOAppDefault:
val prompt = for
input <- readLine("Enter your input (or 'q' to quit): ")
_ <- ZIO.cond(input == "q", (), s"Invalid input: '$input'")
yield ()
// [1] new: schedule, exponential, jittered, and recurs
val retrySchedule =
Schedule.exponential(20.milliseconds)
.jittered && Schedule.recurs(5)
// [2] new: use this schedule, catch errors
val run = prompt.retry(annotatedRetries).catchAll { error =>
printLineError(s"D’oh, error: $error")
}
// 1. use && to combine with number of retry attempts
val limitedRetries =
Schedule.exponential(10.milliseconds)
.jittered && Schedule.recurs(3) // stop after x retry attempts
// 2. use && to combine with duration limit
val timeBoxed =
Schedule.exponential(10.milliseconds)
.jittered && Schedule.elapsed.whileOutput({ duration =>
duration < 5.seconds
}) // stops once the code has been retrying for 5 seconds (elapsed time)
// 3. use || for either condition (NEEDS TESTING)
val eitherCondition =
(Schedule.exponential(10.milliseconds).jittered && Schedule.recurs(5)) ||
(Schedule.exponential(10.milliseconds).jittered &&
Schedule.elapsed.whileOutput({ duration =>
duration < 5.seconds
}))
// 4. use untilOutput to stop when a condition is met
val untilMaxDelay =
Schedule.exponential(10.milliseconds)
.jittered
.untilOutput({ delay =>
//println(s"delay: $delay")
delay >= 1.second
}) // stops when delay value is >= 1 second (eg, 1.28 secs)
// 5. use whileOutput (opposite of untilOutput)
val whileUnderMaxDelay =
Schedule.exponential(10.milliseconds)
.jittered
.whileOutput({ delay =>
delay < 1.second
}) // stops when delay value is !< 1 second (eg, 1.28 secs)
// 6. fibonacci backoff (increase delays less aggressively than exponential)
val fibonacciRetries =
Schedule.fibonacci(10.milliseconds).jittered && Schedule.recurs(5)
// 7. TODO: check into the difference between `fixed` and `spaced`
val fixedDelayRetries =
Schedule.fixed(200.milliseconds) && Schedule.recurs(5)
// 8. fixed intervals:
val fixedIntervalRetries =
Schedule.spaced(100.milliseconds) && Schedule.recurs(5)
// 9. combine schedules with andThen
val sequentialSchedules =
(Schedule.spaced(10.milliseconds) && Schedule.recurs(3)).andThen(
Schedule.spaced(900.milliseconds) && Schedule.recurs(2)
)
// 10. annotate schedules for logging.
// use onDecision to perform side effects like logging.
// TODO: Improve this output.
val annotatedRetries =
Schedule.exponential(10.milliseconds).jittered && Schedule.recurs(5).onDecision { (decision, input, output) =>
// decision: the scheduling decision made after the last attempt
// input: the input value that was provided to the schedule during the last step
// output: the output value produced by the schedule in the last step
ZIO.logInfo(s"Retry decision: $decision")
ZIO.logInfo(s"Input: $input")
ZIO.logInfo(s"Ouput: $output")
}
// example usage in for-expression
def retryOperation[E, A](effect: ZIO[Any, E, A]) =
for
result <- effect.retry(limitedRetries)
_ <- ZIO.logInfo("Operation completed")
yield result
// example with pattern matching
def handleRetry[E, A](result: Exit[E, A]) =
result match
case Exit.Success(value) => ZIO.succeed(value)
case Exit.Failure(cause) => ZIO.logError(s"Failed with: $cause")
/**
* THERE ARE MORE EXAMPLES BELOW HERE, BUT MOST
* ARE REPETITIVE.
*/
// fixed intervals (Schedule.spaced) with a maximum duration
val timeLimitedFixedRetries =
Schedule.spaced(100.milliseconds) && Schedule.elapsed.whileOutput { duration =>
duration < 1.minute
}
// can help with logging and debugging:
val collectRetryDelays =
Schedule.exponential(10.milliseconds).jittered.collectAll
def retryOnNetworkError[E <: Throwable, A](effect: ZIO[Any, E, A]) =
effect.retry(
Schedule.recurs(5).whileInput {
case e: java.io.IOException => true
case _ => false
}
)
If you’re interested in ZIO 2 retries and scheduling examples, I hope these are helpful.