Skip to content

Commit 46db88c

Browse files
committed
Add CmdRetry
1 parent 22534bc commit 46db88c

File tree

5 files changed

+391
-35
lines changed

5 files changed

+391
-35
lines changed

README.md

+6-5
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
Retry failed jsonapi requests with retry policies.
44

5-
It uses the [`Retry` package](https://package.elm-lang.org/packages/choonkeat/elm-retry/latest/) and the [`jsonapi-http` package](https://package.elm-lang.org/packages/calions-app/jsonapi-http/latest/) under the hood.
5+
It is greatly inspired by the [`Retry` package](https://package.elm-lang.org/packages/choonkeat/elm-retry/latest/) but aims to handle http requests from the [`jsonapi-http` package](https://package.elm-lang.org/packages/calions-app/jsonapi-http/latest/).
66

7-
With this package you can add [`retry`](https://package.elm-lang.org/packages/choonkeat/elm-retry/latest/) policies to [`jsonapi-http`](https://package.elm-lang.org/packages/calions-app/jsonapi-http/latest/) requests errors.
7+
With this package you can add retry policies to [`jsonapi-http`](https://package.elm-lang.org/packages/calions-app/jsonapi-http/latest/) requests errors.
88
You can choose specific errors that will trigger a retry: unauthenticated error, network error, etc...
99

10+
You can also send `Cmd`s between 2 failures with the `Http.CmdRetry` module.
11+
1012
## Getting Started
1113

1214
Here is an example retrying requests 5 times maximum with a constant interval between retries, only for unauthenticated and unauthorized errors:
@@ -16,7 +18,6 @@ import Http.Request
1618
import Http.Retry
1719
import Json.Encode
1820
import JsonApi.Decode
19-
import Retry
2021

2122
request : Cmd Msg
2223
request =
@@ -27,8 +28,8 @@ request =
2728
, documentDecoder = JsonApi.Decode.resources "resource-type" entityDecoder
2829
}
2930
|> Http.Retry.with
30-
[ Retry.maxRetries 5
31-
, Retry.exponentialBackoff { interval = 500, maxInterval = 3000 }
31+
[ Http.Retry.maxRetries 5
32+
, Http.Retry.exponentialBackoff { interval = 500, maxInterval = 3000 }
3233
]
3334
[ Http.Retry.onUnauthenticatedStatus
3435
, Http.Retry.onUnauthorizedStatus

elm.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@
55
"license": "MIT",
66
"version": "1.0.0",
77
"exposed-modules": [
8+
"Http.CmdRetry",
89
"Http.Retry"
910
],
1011
"elm-version": "0.19.0 <= v < 0.20.0",
1112
"dependencies": {
1213
"calions-app/jsonapi-http": "1.1.0 <= v < 2.0.0",
13-
"choonkeat/elm-retry": "1.0.1 <= v < 2.0.0",
1414
"elm/core": "1.0.5 <= v < 2.0.0",
1515
"elm/http": "2.0.0 <= v < 3.0.0",
16+
"elm/random": "1.0.0 <= v < 2.0.0",
17+
"elm/time": "1.0.0 <= v < 2.0.0",
1618
"krisajenkins/remotedata": "6.0.1 <= v < 7.0.0"
1719
},
1820
"test-dependencies": {}
19-
}
21+
}

src/Http/CmdRetry.elm

+194
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
module Http.CmdRetry exposing
2+
( with, newAttempt
3+
, RetryContext
4+
)
5+
6+
{-| Tries to perform a `Task` and retry it upon failure, allowing you to execute other `Cmd`s between each failure.
7+
8+
The following example executes a request with a retry policy when the request fails and
9+
executes a port before each new retry:
10+
11+
type Msg
12+
= OnRetry (Http.CmdRetry.RetryContext Msg Entity)
13+
| OnEntityRetrieved (RemoteData.RemoteData Http.Error.RequestError Entity)
14+
15+
type alias Model =
16+
{ entity : RemoteData.RemoteData Http.Error.RequestError Entity }
17+
18+
init : ( Model, Cmd Msg )
19+
init =
20+
( { entity = RemoteData.Loading }
21+
, getEntity
22+
|> Http.CmdRetry.with
23+
[ Http.Retry.maxDuration 7000
24+
, Http.Retry.exponentialBackoff { interval = 500, maxInterval = 3000 }
25+
]
26+
[ Http.Retry.onUnauthenticatedStatus ]
27+
OnRetry
28+
)
29+
30+
update : Msg -> Model -> ( Model, Cmd Msg )
31+
update msg model =
32+
case msg of
33+
OnRetry retryContext ->
34+
( model
35+
, Http.CmdRetry.newAttempt
36+
executePort
37+
OnEntityRetrieved
38+
retryContext
39+
)
40+
41+
OnEntityRetrieved entity ->
42+
( { model | entity = entity }, Cmd.none )
43+
44+
getEntity : Task Never (RemoteData.RemoteData Http.Error.RequestError Entity)
45+
getEntity =
46+
Request.request
47+
{ url = "<http://endpoint">
48+
, headers = []
49+
, body = Json.Encode.object []
50+
, documentDecoder = JsonApi.Decode.resources "resource-type" entityDecoder
51+
}
52+
53+
54+
# Retry
55+
56+
@docs with, newAttempt
57+
58+
59+
# Types
60+
61+
@docs RetryContext
62+
63+
-}
64+
65+
import Http.Error
66+
import Http.Internal as Internal
67+
import RemoteData
68+
import Task exposing (Task)
69+
import Time
70+
71+
72+
{-| Type used by the module to keep the context of the retry process.
73+
You will never handle it directly
74+
75+
`msg` is your `Msg` type and `data` is the data you you to receive from your request.
76+
77+
-}
78+
type RetryContext msg data
79+
= FailedTask (Context msg data)
80+
| FinishedTask (Task Never (RemoteData.RemoteData Http.Error.RequestError data))
81+
82+
83+
type alias Context msg data =
84+
{ partContext : PartContext msg data
85+
, lastError : Http.Error.RequestError
86+
}
87+
88+
89+
type alias PartContext msg data =
90+
{ startTime : Int
91+
, failureConditions : List Internal.FailureCondition
92+
, originalTask : Task Never (RemoteData.RemoteData Http.Error.RequestError data)
93+
, onRetryMsg : RetryContext msg data -> msg
94+
, policies : List (Internal.Policy Http.Error.RequestError)
95+
}
96+
97+
98+
{-| Attempt a new retry from your update function with the `RetryContext` you received.
99+
The first parameter allows you to execute a `Cmd` just before the next retry.
100+
The second parameter is the message you want to send when the request finally succeeded or failed (after all configured retries)
101+
102+
update : Msg -> Model -> ( Model, Cmd Msg )
103+
update msg model =
104+
case msg of
105+
OnRetry retryContext ->
106+
( model
107+
, Http.CmdRetry.newAttempt
108+
(\lastError ->
109+
doSomethingWithLastError lastError
110+
)
111+
OnRequestDone
112+
retryContext
113+
)
114+
115+
-}
116+
newAttempt :
117+
(Http.Error.RequestError -> Cmd msg)
118+
-> (RemoteData.RemoteData Http.Error.RequestError data -> msg)
119+
-> RetryContext msg data
120+
-> Cmd msg
121+
newAttempt onRetryCmd msg retryContext =
122+
case retryContext of
123+
FailedTask ({ partContext, lastError } as context) ->
124+
Cmd.batch
125+
[ onRetryCmd lastError
126+
, retryFromContext partContext
127+
|> Task.perform partContext.onRetryMsg
128+
]
129+
130+
FinishedTask task ->
131+
Task.perform msg task
132+
133+
134+
{-| Tries to execute the given task. You will receive a message with the retry context.
135+
From there you will call `newAttempt` which will handle your request retry, allowing you
136+
to execute a `Cmd` before the next retry.
137+
138+
originalTask
139+
|> Http.CmdRetry.with
140+
[ Http.Retry.maxDuration 7000
141+
, Http.Retry.exponentialBackoff { interval = 500, maxInterval = 3000 }
142+
]
143+
[ Http.Retry.onUnauthenticatedStatus ]
144+
OnRetry
145+
146+
-}
147+
with :
148+
List (Internal.Policy Http.Error.RequestError)
149+
-> List Internal.FailureCondition
150+
-> (RetryContext msg data -> msg)
151+
-> Task Never (RemoteData.RemoteData Http.Error.RequestError data)
152+
-> Cmd msg
153+
with errTasks failureConditions onRetryMsg originalTask =
154+
Task.map Time.posixToMillis Time.now
155+
|> Task.map
156+
(\nowMillis ->
157+
{ policies = errTasks
158+
, startTime = nowMillis
159+
, failureConditions = failureConditions
160+
, onRetryMsg = onRetryMsg
161+
, originalTask = originalTask
162+
}
163+
)
164+
|> Task.andThen retryFromContext
165+
|> Task.perform onRetryMsg
166+
167+
168+
retryFromContext : PartContext msg data -> Task Never (RetryContext msg data)
169+
retryFromContext { startTime, policies, originalTask, onRetryMsg, failureConditions } =
170+
let
171+
onError time currPolicies err =
172+
currPolicies
173+
|> List.map (\((Internal.Policy nextPolicy) as cfg) -> nextPolicy time cfg err)
174+
|> Task.sequence
175+
|> Task.map
176+
(\nextPolicies ->
177+
FailedTask
178+
{ partContext =
179+
{ startTime = time
180+
, failureConditions = failureConditions
181+
, originalTask = originalTask
182+
, onRetryMsg = onRetryMsg
183+
, policies = nextPolicies
184+
}
185+
, lastError = err
186+
}
187+
)
188+
|> Task.onError (RemoteData.Failure >> Task.succeed >> FinishedTask >> Task.succeed)
189+
in
190+
originalTask
191+
|> Task.mapError (always (Http.Error.CustomError "no error"))
192+
|> Task.andThen (Internal.convertToError failureConditions)
193+
|> Task.map (Task.succeed >> FinishedTask)
194+
|> Task.onError (onError startTime policies)

src/Http/Internal.elm

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
module Http.Internal exposing (FailureCondition(..), Policy(..), convertToError)
2+
3+
import Http.Error
4+
import RemoteData
5+
import Task exposing (Task)
6+
7+
8+
type FailureCondition
9+
= FailureCondition (Http.Error.RequestError -> Bool)
10+
11+
12+
type Policy x
13+
= Policy (Int -> Policy x -> x -> Task x (Policy x))
14+
15+
16+
convertToError : List FailureCondition -> RemoteData.RemoteData Http.Error.RequestError data -> Task Http.Error.RequestError (RemoteData.RemoteData Http.Error.RequestError data)
17+
convertToError failureConditions result =
18+
case result of
19+
RemoteData.Loading ->
20+
Task.succeed result
21+
22+
RemoteData.NotAsked ->
23+
Task.succeed result
24+
25+
RemoteData.Success d ->
26+
Task.succeed result
27+
28+
RemoteData.Failure err ->
29+
if List.any (\(FailureCondition f) -> f err) failureConditions then
30+
Task.fail err
31+
32+
else
33+
Task.succeed result

0 commit comments

Comments
 (0)