loading...

Retrying failed requests in Elm... without the headache

1hko profile image 1hko Updated on ・3 min read

I needed a generic way to retry requests that fail. I did not want to litter my model with extra state or clutter my update function by having to handle additional cases for each request's code paths.

I found panosoft/elm-cmd-retry but it depends on native code and meets neither of the requirements set above.

Wishful Thinking

It should be intuitive to use —

decoder
    |> Http.get url
    |> Retry.send DataReceived

It should allow us to retry with configuration —

decoder
    |> Http.get url
    |> Retry.sendWith { retries = 3, interval = 10 * Time.second } DataReceived

Implementation

Here's my best attempt at the Retry module. I tweaked the API thanks to some comments I received on the Elm discourse. The revised implementation also makes it possible to retry any Task using retry or retryWith. It compiles and works as intended in my program.

At a glance —

default : Config
retry : Task x a -> Task x a
retryWith : Config -> Task x a -> Task x a
send : (Result Http.Error a -> msg) -> Http.Request a -> Cmd msg
sendWith : Config -> (Result Http.Error a -> msg) -> Http.Request a -> Cmd msg

Complete implementation —

module Retry exposing (Config, default, retry, retryWith, send, sendWith)

import Http
import Process
import Task exposing (Task)
import Time exposing (Time)


type alias Config =
    { retries : Int
    , interval : Time
    }


default : Config
default =
    { retries = 5
    , interval = 1 * Time.second
    }


retry : Task x a -> Task x a
retry =
    retryWith default


retryWith : Config -> Task x a -> Task x a
retryWith config task =
    task |> Task.onError (onError config task)


send : (Result Http.Error a -> msg) -> Http.Request a -> Cmd msg
send =
    sendWith default


sendWith : Config -> (Result Http.Error a -> msg) -> Http.Request a -> Cmd msg
sendWith config resultToMessage req =
    let
        task =
            Http.toTask req
    in
        task
            |> Task.onError (onError config task)
            |> Task.attempt resultToMessage


onError : Config -> Task x a -> x -> Task x a
onError config task error =
    if config.retries == 0 then
        let
            _ =
                Debug.log "failed retrying" error
        in
            Task.fail error
    else
        let
            _ =
                Debug.log ("retrying " ++ (toString config.retries)) error

            next =
                task
                    |> Task.onError (onError { config | retries = config.retries - 1 } task)
        in
            Process.sleep config.interval
                |> Task.andThen (always next)

How to use it

As demonstrated in the example below we meet our goals. Our model is not required to keep track of extra state and it's easy to convert any single-attempt request into a request that retries itself.

module SomeModule exposing (..)

import Retry
import User exposing (User)
...


type alias Model =
    { users : List User }


type Msg
    = UsersReceived (Result Http.Error (List User))


init : ( Model, Cmd Msg )
init =
    ( { users = [] }, getUsers )


getUsers : Cmd Msg
getUsers =
    (Decode.list User.decode)
        |> Http.get "/users.json"
        |> Retry.send UsersReceived


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        UsersReceived (Ok users) ->
            { model | users = users }
                ! []

        UsersReceived (Err e) ->
            let
                _ =
                    Debug.log "request failed" e
            in
                ( model, Cmd.none )

Feedback

I'm new to Elm and I'm asking the community for general feedback on this module.

  • Can you see problems I may encounter with it?
  • Can you see ways in which it can be improved?
  • Am I breaking any Elm conventions?

Original API

Originally I had written the Retry module with retry and retryWith functions that returned a Task. This required the programmer to use Task.attempt to create the necessary Cmd. If possible, it's best to keep this implementation detail out of the programmer's mind entirely. The Retry.send and Retry.sendWith API remedies this problem

-- original API leaks Task abstraction
decoder
    |> Http.get url
    |> Retry.retry
    |> Task.attempt DataReceived

-- proposal
decoder
    |> Http.get url
    |> Retry.retry DataReceived

-- proposal with configuration
decoder
    |> Http.get url
    |> Retry.retryWith { ... } DataReceived

-- new API
decoder
    |> Http.get url
    |> Retry.send DataReceived

-- new API with configuration
decoder
    |> Http.get url
    |> Retry.sendWith { retries = 3, interval = 10 * Time.second } DataReceived

Discussion

pic
Editor guide
Collapse
kspeakman profile image
Kasey Speakman

Nice post.

I don't immediately see any technical problems. I think you will have to deal with Tasks to support the functionality, but you could provide your own function on Retry to turn it into a Cmd Msg.

Based on my experience, automatic retries can by problematic for user experience. It can make sense in scenarios like trying to sync some data in the background. But in cases where the user is waiting on the response, I like to notify users of any problem and leave their page/data in a state where they can choose to retry themselves.

Just my 2 cents, FWIW.

Collapse
1hko profile image
1hko Author

Helpful feedback, thank you. Maybe something like "Automatically retrying in X sec ..." combined with a "Retry now" button. Now that we talk about it, I feel like I've seen this kind of "retry" interface many times. I didn't realize I already had a good example to guide me!