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
KnockerUpperbased 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
- Flowchart
- BPMN
- Model
- Debug
{
"base" : {
"meta" : {
"name" : "Do Something",
"error" : null
},
"_type" : "Pure"
},
"_type" : "Retried"
}
[Retried](no-name)
- base: [Pure](Do Something)
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
- Flowchart
- BPMN
- Model
- Debug
{
"base" : {
"meta" : {
"name" : "Api Call",
"error" : null,
"description" : null
},
"_type" : "RunIO"
},
"_type" : "Retried"
}
[Retried](no-name)
- base: [RunIO](Api Call)