Composable backoff retry for Task?

UPDATE: I’ve published the result from this discussion as https://package.elm-lang.org/packages/choonkeat/elm-retry/latest/

Background:

In Go, there’s library https://godoc.org/github.com/cenkalti/backoff and I’d create my backoff logic by composing various functions:

  b := backoff.WithContext(
          backoff.WithMaxRetries( 10,           // if we retried 10 times, do not retry anymore
              backoff.NewExponentialBackOff()), // retry interval backs off exponentially
                  ctx)                          // if ctx was cancelled, e.g. ctrl-c, then do not retry anymore
  
  backoff.Retry(func() error {
          // do stuff that will possibly fail
  }, b)   // supply our retry policy, `b`

So, when trying to write a module to retry Task in Elm, I was originally looking to see how I could make the retry policy similarly composable.

But I was unable to come up with an approach – is it possible? Here’s my attempt, using a Config record https://gist.github.com/choonkeat/28b9264c64dd2b0243ab18cdbba4938a/1c57fe385dba6b6b7cc2daff91d6725369f4aa7a (updated link to specific old revision)

Also, since the functions are returning Task x a… I can’t really test it?

Any pointers or advice would be greatly appreciated!

The idea of using a Record is a good idea.

The main strategy I would use is to have some kind of state needed by the logic of the retries and then a set of functions that take this state to the next retry. You would then need to figure out a way to compose those functions that take the state to the next level. This needs to be designed since there are conflicts in timing between strategies.

Here is some code to get you started. In this version, one of the strategies has had its sleepTime reduced to 0 because otherwise, Task.map2 would have taken twice as long (the tasks run sequentially, not in parallel) .

module Main exposing (init, main)

import Process
import Task exposing (Task)
import Time exposing (Posix)


task =
    Time.now
        |> Task.andThen (\t -> Task.fail (Debug.toString t))


type Config
    = Config
        { next : Config -> Task String Config
        , sleepTime : Float
        , prevTime : Posix
        , retriesLeft : Int
        }


simpleRetry : Config -> Task String Config
simpleRetry (Config conf) =
    Process.sleep conf.sleepTime
        |> Task.map (\_ -> Config { conf | retriesLeft = conf.retriesLeft - 1 })


retryUntil : Posix -> Config -> Task String Config
retryUntil end (Config conf) =
    Time.now
        |> Task.andThen
            (\now ->
                if Time.posixToMillis now < Time.posixToMillis end then
                    Process.sleep conf.sleepTime
                        |> Task.map (\_ -> Config conf)

                else
                    Task.succeed (Config { conf | retriesLeft = 0 })
            )


simpleConf : Config
simpleConf =
    Config
        { next = simpleRetry
        , sleepTime = 1000
        , prevTime = Time.millisToPosix 0
        , retriesLeft = 5
        }


retry (Config conf) rawTask =
    let
        errorHandler _ =
            conf.next (Config conf)
                |> Task.andThen
                    (\(Config newConf) ->
                        if newConf.retriesLeft == 0 then
                            Debug.log "no more" <| Task.fail "no more retries"

                        else
                            retry (Config <| Debug.log "retrying" newConf) rawTask
                    )
    in
    Task.onError errorHandler rawTask


combined until (Config mainConf) =
    let
        merge (Config c1) (Config c2) =
            Config { c1 | retriesLeft = min c1.retriesLeft c2.retriesLeft }
    in
    Config
        { mainConf
            | next =
                \(Config conf) ->
                    Task.map2 merge (simpleRetry (Config conf)) (retryUntil until (Config { conf | sleepTime = 0 }))
        }


combinedRetriedTask =
    Time.now
        |> Task.andThen
            (\now ->
                retry (combined (Time.posixToMillis now + 3100 |> Time.millisToPosix) simpleConf) task
            )


simpleRetriedTask =
    retry simpleConf task


init : () -> ( Int, Cmd Msg )
init _ =
    ( 1, Task.attempt TaskEnded combinedRetriedTask )


type Msg
    = TaskEnded (Result String String)


main =
    Platform.worker
        { init = init
        , update = \_ m -> ( m, Cmd.none )
        , subscriptions = \_ -> Sub.none
        }

2 Likes

I wrote this post on Elm discourse that I think you will find helpful. Retrying failed requests... without the headache

1 Like

thank you! i like this signature. i’ll digest and update :bowing_man:

also, i was thinking perhaps my goal of “composing” into a single Config was misguided; maybe List Config is all i needed

thank you! but seems a tad too specific to Http

I think what is specific to HTTP is that since http 2 it’s not possible to create task and thus not even chain the requests. Or si there any way how to reasonably work with requests in http 2 or convert them to Tasks that I;m missing?

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 https://gist.github.com/choonkeat/14c0ecc8f6ba929a76ee03647cb96ded

2 Likes

:boom: I indeed really missed that one.

See the final revision. It’s generic for Task and specialized for Http.

1 Like

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.