I took this clue, and explored doing a List
(instead of actually composing into 1x record via merge
)… and a few code golf later, I ended up with something I’m happy with
First, the usage looks like this
-- with : List (ErrTask x) -> Task x a -> Task x a
Retry.with
[ Retry.maxRetries 20
, Retry.maxDuration 7000
, Retry.exponentialBackoff { interval = 500, maxInterval = 3000 }
]
myTask
The user could provide conflict or redundant ErrTask x
in the list, e.g. doing a 800ms sleep and also a 200ms sleep – Retry.with
will happily sleep 1000ms between retries.
But this makes Retry.with
much simpler to implement, basically a wrapper for Task.sequence
– on error, do all these things then try out rawTask again; when any ErrTask x
fail, retries will stop.
Also, I wanted, as much as possible, to have each strategy be concern with only what they need (some count the number of tries, some doesn’t need that info) so I moved most of those state into their own closure and the Config
record (renamed to ErrTask
) was left with only the function, and thus not a record anymore!
I’ve updated my code
module Retry exposing (ErrTask(..), constantInterval, exponentialBackoff, maxDuration, maxRetries, with)
{-| Retry a task with list of retry policies
config =
[ Retry.maxDuration 7000
, Retry.exponentialBackoff { interval = 500, maxInterval = 3000 }
]
doTask
|> Retry.with config
|> Task.attempt DidTask
-}
import Process
import Random
import Task exposing (Task)
import Time exposing (now)
{-| Specifies a task to run that will return the new task to run on the next error.
The arguments are
- `Int` when we originally started our rawTask, in milliseconds
- `ErrTask x` is the current `ErrTask`; destructure to obtain the function to call
- `x` refers to the latest error from the rawTask
-}
type ErrTask x
= ErrTask (Int -> ErrTask x -> x -> Task x (ErrTask x))
{-| given a List of `ErrTask` we add retries to our rawTask
-}
with : List (ErrTask x) -> Task x a -> Task x a
with errTasks rawTask =
let
onError startTime currErrTasks err =
currErrTasks
|> List.map (\((ErrTask nextErrTask) as cfg) -> nextErrTask startTime cfg err)
|> Task.sequence
|> Task.andThen (\nextErrTasks -> Task.onError (onError startTime nextErrTasks) rawTask)
in
Task.map Time.posixToMillis Time.now
|> Task.andThen
(\nowMillis -> Task.onError (onError nowMillis errTasks) rawTask)
--
--
-- Some out-of-the-box `ErrTask x` to use; combine them into a List for `with`
--
--
{-| limit by "number of retries"
Retry.with [ Retry.maxRetries 20 ] doWork
|> Task.attempt DidWork
NOTE: the code above does NOT sleep between retries; see `constantInterval` or `exponentialBackoff`
-}
maxRetries : Int -> ErrTask x
maxRetries int =
let
nextErrTask _ _ err =
if int <= 0 then
Task.fail err
else
Task.succeed (maxRetries (int - 1))
in
ErrTask nextErrTask
{-| limit by "how long it took"
Retry.with [ Retry.maxDuration 7000 ] doWork
|> Task.attempt DidWork
NOTE: the code above does NOT sleep between retries; see `constantInterval` or `exponentialBackoff`
-}
maxDuration : Int -> ErrTask x
maxDuration duration =
let
nextErrTask startTime sameTask err =
Task.map Time.posixToMillis Time.now
|> Task.andThen
(\now ->
if now - startTime >= duration then
Task.fail err
else
Task.succeed sameTask
)
in
ErrTask nextErrTask
{-| sleep at constant interval between retries
Retry.with [ Retry.constantInterval 1000 ] doWork
|> Task.attempt DidWork
NOTE: the code above will keep retrying rawTask; see `maxRetries` or `maxDuration`
-}
constantInterval : Float -> ErrTask x
constantInterval duration =
let
nextErrTask _ sameTask _ =
Process.sleep duration
|> Task.andThen (\_ -> Task.succeed sameTask)
in
ErrTask nextErrTask
{-| sleep longer and longer between retries
Retry.with [ Retry.exponentialBackoff { interval = 500, maxInterval = 3000 } ] doWork
|> Task.attempt DidWork
NOTE: the code above will keep retrying rawTask; see `maxRetries` or `maxDuration`
-}
exponentialBackoff : { interval : Float, maxInterval : Float } -> ErrTask x
exponentialBackoff { interval, maxInterval } =
let
backoffWith seed currInterval =
let
( calcInterval, nextSeed ) =
Random.step
(nextIntervalGenerator { randomizationFactor = 0.5, multiplier = 1.5, interval = currInterval })
seed
nextErrTask _ _ err =
Process.sleep currInterval
|> Task.andThen (\_ -> Task.succeed (backoffWith nextSeed (min calcInterval maxInterval)))
in
ErrTask nextErrTask
in
backoffWith (Random.initialSeed 0) interval
{-| Based off <https://github.com/cenkalti/backoff/blob/4b4cebaf850ec58f1bb1fec5bdebdf8501c2bc3f/exponential.go#L144-L153>
-}
nextIntervalGenerator : { randomizationFactor : Float, multiplier : Float, interval : Float } -> Random.Generator Float
nextIntervalGenerator { randomizationFactor, multiplier, interval } =
let
minInterval =
interval * randomizationFactor
maxInterval =
interval * (1 + randomizationFactor)
in
Random.float 0 1
|> Random.map (\randf -> multiplier * (minInterval + (randf * (maxInterval - minInterval + 1))))
Thanks again @pdamoc
Yes! Http.task
is somehow easily missed.
Something new you’ll probably need is a resolver : Resolver x a
; the 2 common ones I use can be referenced at Http.task { resolver = Http.stringResolver decoder, ... } · GitHub