Skip to main content

Retrying Operations

Retries enable workflows to automatically recover from transient technical failures by reattempting failed operations after a delay.

Error Types: Technical vs Business

It's important to distinguish between:

  • Technical errors – Infrastructure-level problems like network timeouts or service unavailability. These are typically transient and can be retried.
  • Business errors – Domain-specific conditions like invalid input or failed validations. These require explicit handling and should not be retried.

Use error handling for managing business errors.

How Retry Works

When the WorkflowInstance#wakeup is called and the underlying operation fails:

  • Without retry: the error is propagated to the caller.
  • With retry: the error is swallowed, and a future wakeup is registered within KnockerUpper based on retry logic.

Retry Strategies

Workflows4s supports two types of retries: stateless and stateful.

Stateless Retry

Stateless retries are simple and easy to use. They allow rescheduling the wakeup but without tracking any state like attempt count, elapsed time or any other information about previous executions.

This means you can't directly express strategies such as exponential retries.

To support such logic, you can:

  • Use custom persistent state to track retry metadata and query it in the retry handler (outside workflows4s).
  • Use stateful retries described below.

Simple Retry with Fixed Delay

val doSomething: WIO[Any, Nothing, MyState] = WIO.pure(MyState(1)).autoNamed

val withRetry = doSomething.retry.statelessly.wakeupIn {
case _: TimeoutException => Duration.ofMinutes(2)
case _: UnknownHostException => Duration.ofMinutes(15)
}
Rendering Outputs

Advanced Retry with Custom Logic

val doSomething: WIO[Any, Nothing, MyState] = WIO.pure(MyState(1)).autoNamed

val withRetry = doSomething.retry.statelessly.wakeupAt { (input, error, state) =>
error match {
case _: TimeoutException => IO.pure(Some(Instant.now().plus(Duration.ofMinutes(2))))
case _ => IO.pure(None) // Don't retry other errors
}
}

Stateful Retry

For more complex retry logic, you can use stateful retries, where the state is persisted through events, same as workflow state.

Moreover, this approach allows you to recover from errors because it produces events and events can modify the workflow state.

val doSomething: WIO[Any, Nothing, MyState] = WIO.pure(MyState(1)).autoNamed
type RetryCounter = Int

val withRetry: WIO[Any, Nothing, MyState] =
doSomething.retry
.usingState[RetryCounter]
.onError(in => {
in.error match {
case _: TimeoutException =>
IO(
WIO.Retry.Stateful.Result.ScheduleWakeup(
at = Instant.now().plus(Duration.ofMinutes(30)),
event = Some(MyRetryEvent),
),
)
case _: IllegalArgumentException =>
IO(WIO.Retry.Stateful.Result.Recover(MyEvent()))
case _ =>
IO(WIO.Retry.Stateful.Result.Ignore)
}
})
.handleEventsWith(in =>
in.event match {
case MyRetryEvent => (in.retryState.getOrElse(0) + 1).asLeft
case e: MyEvent => Right(Right(MyState(1)))
},
)

Caveats

Choose the Right Layer

Use workflow-level retries for retry schedules spanning minutes to hours or days

For short-lived retries (e.g., retrying within milliseconds or seconds), prefer handling them directly inside the IO operation using libraries like cats-retry.

Drafting Support

Retries come with drafting support.

val apiCall = WIO.draft.step()

val withRetry = WIO.draft.retry(apiCall)

// or with a postfix application
import WIO.draft.syntax.*
val withRetry2 = apiCall.draftRetry
Rendering Outputs