ZIO 2 Retry and Scheduling Cheat Sheet

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

  1. Always Add Jitter: For distributed systems, always use .jittered to prevent synchronized retry storms.
  2. Combine Limits: Use both time and attempt limits for robust retry policies.
  3. Log Retry Attempts: Include retry logging for debugging and monitoring.
  4. Consider Backoff Strategy: Choose between exponential, fibonacci, or fixed delays based on your use case.
  5. 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.