Skip to content

Commit 38fc5d5

Browse files
Replace withBackoff with more explicit alternatives (#638)
1 parent 23cbb4b commit 38fc5d5

File tree

5 files changed

+201
-147
lines changed

5 files changed

+201
-147
lines changed

app/src/App/Effect/GitHub.purs

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -258,18 +258,25 @@ request octokit githubRequest@{ route: route@(GitHubRoute method _ _), codec } =
258258
requestWithBackoff :: forall a r. Octokit -> Request a -> Run (LOG + AFF + r) (Either Octokit.GitHubError a)
259259
requestWithBackoff octokit githubRequest = do
260260
Log.debug $ "Making request to " <> Octokit.printGitHubRoute githubRequest.route
261-
let action = Octokit.request octokit githubRequest
262-
result <- Run.liftAff $ withBackoff
263-
{ delay: Duration.Milliseconds 5_000.0
264-
, action
265-
, shouldCancel: \_ -> Octokit.request octokit Octokit.rateLimitRequest >>= case _ of
266-
Right { remaining } | remaining == 0 -> pure false
267-
_ -> pure true
268-
, shouldRetry: \attempt -> if attempt <= 3 then pure (Just action) else pure Nothing
269-
}
261+
result <- Run.liftAff do
262+
let
263+
retryOptions =
264+
{ timeout: defaultRetry.timeout
265+
, retryOnCancel: defaultRetry.retryOnCancel
266+
, retryOnFailure: \attempt err -> case err of
267+
UnexpectedError _ -> false
268+
DecodeError _ -> false
269+
-- https://docs.github.com/en/rest/overview/resources-in-the-rest-api?apiVersion=2022-11-28#exceeding-the-rate-limit
270+
APIError { statusCode } | statusCode >= 400 && statusCode <= 500 -> false
271+
APIError _ -> attempt <= 3
272+
}
273+
withRetry retryOptions (Octokit.request octokit githubRequest)
270274
case result of
271-
Nothing -> pure $ Left $ APIError { statusCode: 400, message: "Unable to reach GitHub servers." }
272-
Just accepted -> pure accepted
275+
Cancelled -> pure $ Left $ APIError { statusCode: 400, message: "Unable to reach GitHub servers." }
276+
Failed err -> do
277+
Log.debug $ "Request failed with error: " <> Octokit.printGitHubError err
278+
pure $ Left err
279+
Succeeded success -> pure $ Right success
273280

274281
type RequestResult =
275282
{ modified :: DateTime

app/src/App/Effect/Pursuit.purs

Lines changed: 36 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ import Data.HTTP.Method as Method
1616
import Data.Map as Map
1717
import Data.MediaType.Common as MediaType
1818
import Data.Profunctor as Profunctor
19-
import Effect.Aff (Milliseconds(..))
20-
import Effect.Aff as Aff
2119
import Registry.App.Effect.Log (LOG)
2220
import Registry.App.Effect.Log as Log
2321
import Registry.App.Legacy.LenientVersion (LenientVersion(..))
@@ -64,50 +62,46 @@ handleAff (GitHubToken token) = case _ of
6462
Publish payload reply -> do
6563
Log.debug "Pushing to Pursuit..."
6664

67-
let
68-
loop n = do
69-
result <- Run.liftAff $ withBackoff' $ Affjax.Node.request
70-
{ content: Just $ RequestBody.json payload
71-
, headers:
72-
[ RequestHeader.Accept MediaType.applicationJSON
73-
, RequestHeader.RequestHeader "Authorization" ("token " <> token)
74-
]
75-
, method: Left Method.POST
76-
, username: Nothing
77-
, withCredentials: false
78-
, password: Nothing
79-
, responseFormat: ResponseFormat.string
80-
, timeout: Nothing
81-
, url: "https://pursuit.purescript.org/packages"
82-
}
83-
84-
case result of
85-
Nothing -> do
86-
Log.error $ "Pursuit failed to connect after several retries."
87-
pure $ Left $ "Expected to receive a 201 status from Pursuit, but failed to connect after several retries."
88-
Just (Right { status: StatusCode status })
89-
| status == 201 -> do
90-
Log.debug "Received 201 status, which indicates the upload was successful."
91-
pure $ Right unit
92-
| n > 0, status == 400 || status == 502 -> do
93-
Log.debug $ "Received " <> show status <> ", retrying..."
94-
Run.liftAff $ Aff.delay $ Milliseconds 1000.0
95-
loop (n - 1)
96-
Just (Right { body, status: StatusCode status }) -> do
65+
result <- Run.liftAff $ withRetryRequest'
66+
{ content: Just $ RequestBody.json payload
67+
, headers:
68+
[ RequestHeader.Accept MediaType.applicationJSON
69+
, RequestHeader.RequestHeader "Authorization" ("token " <> token)
70+
]
71+
, method: Left Method.POST
72+
, username: Nothing
73+
, withCredentials: false
74+
, password: Nothing
75+
, responseFormat: ResponseFormat.string
76+
, timeout: Nothing
77+
, url: "https://pursuit.purescript.org/packages"
78+
}
79+
80+
result' <- case result of
81+
Cancelled -> do
82+
Log.error $ "Pursuit failed to connect after several retries."
83+
pure $ Left $ "Expected to receive a 201 status from Pursuit, but failed to connect after several retries."
84+
Failed reqError -> case reqError of
85+
AffjaxError err -> do
86+
pure $ Left $ "Pursuit publishing failed with an HTTP error: " <> Affjax.Node.printError err
87+
StatusError { body, status: StatusCode status } -> do
88+
Log.error $ "Pursuit publishing failed with status " <> show status <> " and body\n" <> body
89+
pure $ Left $ "Expected to receive a 201 status from Pursuit, but received " <> show status <> " instead."
90+
Succeeded { body, status: StatusCode status }
91+
| status == 201 -> do
92+
Log.debug "Received 201 status, which indicates the upload was successful."
93+
pure $ Right unit
94+
| otherwise -> do
9795
Log.error $ "Pursuit publishing failed with status " <> show status <> " and body\n" <> body
9896
pure $ Left $ "Expected to receive a 201 status from Pursuit, but received " <> show status <> " instead."
99-
Just (Left httpError) -> do
100-
let printedError = Affjax.Node.printError httpError
101-
Log.error $ "Pursuit publishing failed because of an HTTP error: " <> printedError
102-
pure $ Left "Could not reach Pursuit due to an HTTP error."
10397

104-
reply <$> loop 2
98+
pure $ reply result'
10599

106100
GetPublishedVersions pname reply -> do
107101
let name = PackageName.print pname
108102
let url = "https://pursuit.purescript.org/packages/purescript-" <> name <> "/available-versions"
109103
Log.debug $ "Checking if package docs for " <> name <> " are published on Pursuit using endpoint " <> url
110-
result <- Run.liftAff $ withBackoff' $ Affjax.Node.request
104+
result <- Run.liftAff $ withRetryRequest'
111105
{ content: Nothing
112106
, headers: [ RequestHeader.Accept MediaType.applicationJSON ]
113107
, method: Left Method.GET
@@ -120,17 +114,17 @@ handleAff (GitHubToken token) = case _ of
120114
}
121115

122116
case result of
123-
Nothing -> do
117+
Cancelled -> do
124118
Log.error $ "Could not reach Pursuit after multiple retries at URL " <> url
125119
pure $ reply $ Left $ "Could not reach Pursuit to determine published versions for " <> name
126-
Just (Left httpError) -> do
120+
Failed (AffjaxError httpError) -> do
127121
let printedError = Affjax.Node.printError httpError
128122
Log.error $ "Pursuit publishing failed because of an HTTP error: " <> printedError
129123
pure $ reply $ Left "Could not reach Pursuit due to an HTTP error."
130-
Just (Right { body, status: StatusCode status }) | status /= 200 -> do
124+
Failed (StatusError { body, status: StatusCode status }) -> do
131125
Log.error $ "Could not fetch published versions from Pursuit (received non-200 response) " <> show status <> " and body\n" <> Argonaut.stringify body
132126
pure $ reply $ Left $ "Received non-200 response from Pursuit: " <> show status
133-
Just (Right { body }) -> case CA.decode availableVersionsCodec body of
127+
Succeeded { body } -> case CA.decode availableVersionsCodec body of
134128
Left error -> do
135129
let printed = CA.printJsonDecodeError error
136130
Log.error $ "Failed to decode body " <> Argonaut.stringify body <> "\n with error: " <> printed

app/src/App/Effect/Source.purs

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import Registry.App.Prelude
55

66
import Affjax.Node as Affjax.Node
77
import Affjax.ResponseFormat as ResponseFormat
8-
import Affjax.StatusCode (StatusCode(..))
98
import Data.Array as Array
109
import Data.DateTime (DateTime)
1110
import Data.HTTP.Method (Method(..))
@@ -79,10 +78,10 @@ handle = case _ of
7978
clonePackageAtTag = do
8079
let url = Array.fold [ "https://github.com/", owner, "/", repo ]
8180
let args = [ "clone", url, "--branch", ref, "--single-branch", "-c", "advice.detachedHead=false", repoDir ]
82-
withBackoff' (Git.gitCLI args Nothing) >>= case _ of
83-
Nothing -> Aff.throwError $ Aff.error $ "Timed out attempting to clone git tag: " <> url <> " " <> ref
84-
Just (Left err) -> Aff.throwError $ Aff.error err
85-
Just (Right _) -> pure unit
81+
withRetryOnTimeout (Git.gitCLI args Nothing) >>= case _ of
82+
Cancelled -> Aff.throwError $ Aff.error $ "Timed out attempting to clone git tag: " <> url <> " " <> ref
83+
Failed err -> Aff.throwError $ Aff.error err
84+
Succeeded _ -> pure unit
8685

8786
Run.liftAff (Aff.attempt clonePackageAtTag) >>= case _ of
8887
Left error -> do
@@ -131,23 +130,23 @@ handle = case _ of
131130
let archiveUrl = "https://github.com/" <> owner <> "/" <> repo <> "/archive/" <> tarballName
132131
Log.debug $ "Fetching tarball from GitHub: " <> archiveUrl
133132

134-
response <- Run.liftAff $ withBackoff' $ Affjax.Node.request $ Affjax.Node.defaultRequest
133+
response <- Run.liftAff $ withRetryRequest' $ Affjax.Node.defaultRequest
135134
{ method = Left GET
136135
, responseFormat = ResponseFormat.arrayBuffer
137136
, url = archiveUrl
138137
}
139138

140139
case response of
141-
Nothing -> Except.throw $ "Could not download " <> archiveUrl
142-
Just (Left error) -> do
140+
Cancelled -> Except.throw $ "Could not download " <> archiveUrl
141+
Failed (AffjaxError error) -> do
143142
Log.error $ "Failed to download " <> archiveUrl <> " because of an HTTP error: " <> Affjax.Node.printError error
144143
Except.throw $ "Could not download " <> archiveUrl
145-
Just (Right { status, body }) | status /= StatusCode 200 -> do
144+
Failed (StatusError { status, body }) -> do
146145
buffer <- Run.liftEffect $ Buffer.fromArrayBuffer body
147146
bodyString <- Run.liftEffect $ Buffer.toString UTF8 (buffer :: Buffer)
148147
Log.error $ "Failed to download " <> archiveUrl <> " because of a non-200 status code (" <> show status <> ") with body " <> bodyString
149148
Except.throw $ "Could not download " <> archiveUrl
150-
Just (Right { body }) -> do
149+
Succeeded { body } -> do
151150
Log.debug $ "Successfully downloaded " <> archiveUrl <> " into a buffer."
152151
buffer <- Run.liftEffect $ Buffer.fromArrayBuffer body
153152
Run.liftAff (Aff.attempt (FS.Aff.writeFile absoluteTarballPath buffer)) >>= case _ of

app/src/App/Effect/Storage.purs

Lines changed: 43 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import Registry.App.Prelude
2020

2121
import Affjax.Node as Affjax.Node
2222
import Affjax.ResponseFormat as ResponseFormat
23-
import Affjax.StatusCode (StatusCode(..))
2423
import Data.Array as Array
2524
import Data.Exists as Exists
2625
import Data.HTTP.Method (Method(..))
@@ -34,6 +33,7 @@ import Registry.App.Effect.Cache as Cache
3433
import Registry.App.Effect.Log (LOG)
3534
import Registry.App.Effect.Log as Log
3635
import Registry.Constants as Constants
36+
import Registry.Foreign.S3 (Space)
3737
import Registry.Foreign.S3 as S3
3838
import Registry.PackageName as PackageName
3939
import Registry.Version as Version
@@ -106,13 +106,13 @@ connectS3 key = do
106106
let bucket = "purescript-registry"
107107
let space = "ams3.digitaloceanspaces.com"
108108
Log.debug $ "Connecting to the bucket " <> bucket <> " at space " <> space <> " with public key " <> key.key
109-
Run.liftAff (withBackoff' (Aff.attempt (S3.connect key "ams3.digitaloceanspaces.com" bucket))) >>= case _ of
110-
Nothing ->
109+
Run.liftAff (withRetryOnTimeout (Aff.attempt (S3.connect key "ams3.digitaloceanspaces.com" bucket))) >>= case _ of
110+
Cancelled ->
111111
Except.throw "Timed out when attempting to connect to S3 storage backend."
112-
Just (Left err) -> do
112+
Failed err -> do
113113
Log.error $ "Failed to connect to S3 due to an exception: " <> Aff.message err
114114
Except.throw "Could not connect to storage backend."
115-
Just (Right connection) -> do
115+
Succeeded connection -> do
116116
Log.debug "Connected to S3!"
117117
pure connection
118118

@@ -126,17 +126,10 @@ handleS3 :: forall r a. S3Env -> Storage a -> Run (LOG + AFF + EFFECT + r) a
126126
handleS3 env = Cache.interpret _storageCache (Cache.handleFs env.cache) <<< case _ of
127127
Query name reply -> map (map reply) Except.runExcept do
128128
s3 <- connectS3 env.s3
129-
resources <- Run.liftAff (withBackoff' (S3.listObjects s3 { prefix: PackageName.print name <> "/" })) >>= case _ of
130-
Nothing -> do
131-
Log.error $ "Failed to list S3 objects for " <> PackageName.print name <> " because the process timed out."
132-
Except.throw $ "Could not upload package " <> PackageName.print name <> " due to an error connecting to the storage backend."
133-
Just objects ->
134-
pure $ map _.key objects
135-
pure $ Set.fromFoldable
136-
$ resources
137-
>>= \resource -> do
138-
{ name: parsedName, version } <- Array.fromFoldable $ parsePackagePath resource
139-
version <$ guard (name == parsedName)
129+
resources <- Except.rethrow =<< Run.liftAff (withRetryListObjects s3 name)
130+
pure $ Set.fromFoldable $ resources >>= \resource -> do
131+
{ name: parsedName, version } <- Array.fromFoldable $ parsePackagePath resource
132+
version <$ guard (name == parsedName)
140133

141134
Download name version path reply -> map (map reply) Except.runExcept do
142135
let package = formatPackageVersion name version
@@ -167,24 +160,21 @@ handleS3 env = Cache.interpret _storageCache (Cache.handleFs env.cache) <<< case
167160

168161
Log.debug $ "Read file for " <> package <> ", now uploading to " <> packagePath <> "..."
169162
s3 <- connectS3 env.s3
170-
published <- Run.liftAff (withBackoff' (S3.listObjects s3 { prefix: PackageName.print name <> "/" })) >>= case _ of
171-
Nothing -> do
172-
Log.error $ "Failed to list S3 objects for " <> PackageName.print name <> " because the process timed out."
173-
Except.throw $ "Could not upload package " <> package <> " due to an error connecting to the storage backend."
174-
Just objects ->
175-
pure $ map _.key objects
176-
163+
published <- Except.rethrow =<< Run.liftAff (withRetryListObjects s3 name)
177164
if Array.elem packagePath published then do
178165
Log.error $ packagePath <> " already exists on S3."
179166
Except.throw $ "Could not upload " <> package <> " because a package at " <> formatPackageUrl name version <> " already exists."
180167
else do
181168
Log.debug $ "Uploading release to the bucket at path " <> packagePath
182169
let putParams = { key: packagePath, body: buffer, acl: S3.PublicRead }
183-
Run.liftAff (withBackoff' (S3.putObject s3 putParams)) >>= case _ of
184-
Nothing -> do
185-
Log.error "Failed to put object to S3 because the process timed out."
170+
Run.liftAff (withRetryOnTimeout (Aff.attempt (S3.putObject s3 putParams))) >>= case _ of
171+
Cancelled -> do
172+
Log.error "Failed to upload object to S3 because the process timed out."
173+
Except.throw $ "Could not upload package " <> package <> " due to an error connecting to the storage backend."
174+
Failed error -> do
175+
Log.error $ "Failed to upload object to S3 because of an exception: " <> Aff.message error
186176
Except.throw $ "Could not upload package " <> package <> " due to an error connecting to the storage backend."
187-
Just _ ->
177+
Succeeded _ ->
188178
Log.info $ "Uploaded " <> package <> " to the bucket at path " <> packagePath
189179

190180
Delete name version reply -> map (map reply) Except.runExcept do
@@ -194,21 +184,18 @@ handleS3 env = Cache.interpret _storageCache (Cache.handleFs env.cache) <<< case
194184

195185
Log.debug $ "Deleting " <> package
196186
s3 <- connectS3 env.s3
197-
published <- Run.liftAff (withBackoff' (S3.listObjects s3 { prefix: PackageName.print name <> "/" })) >>= case _ of
198-
Nothing -> do
199-
Log.error $ "Failed to delete " <> package <> " because the process timed out when attempting to list objects at " <> packagePath <> " from S3."
200-
Except.throw $ "Could not delete " <> package <> " from the storage backend."
201-
Just objects ->
202-
pure $ map _.key objects
203-
187+
published <- Except.rethrow =<< Run.liftAff (withRetryListObjects s3 name)
204188
if Array.elem packagePath published then do
205189
Log.debug $ "Deleting release from the bucket at path " <> packagePath
206190
let deleteParams = { key: packagePath }
207-
Run.liftAff (withBackoff' (S3.deleteObject s3 deleteParams)) >>= case _ of
208-
Nothing -> do
191+
Run.liftAff (withRetryOnTimeout (Aff.attempt (S3.deleteObject s3 deleteParams))) >>= case _ of
192+
Cancelled -> do
209193
Log.error $ "Timed out when attempting to delete the release of " <> package <> " from S3 at the path " <> packagePath
210194
Except.throw $ "Could not delete " <> package <> " from the storage backend."
211-
Just _ -> do
195+
Failed error -> do
196+
Log.error $ "Failed to delete object from S3 because of an exception: " <> Aff.message error
197+
Except.throw $ "Could not delete package " <> package <> " due to an error connecting to the storage backend."
198+
Succeeded _ -> do
212199
Log.debug $ "Deleted release of " <> package <> " from S3 at the path " <> packagePath
213200
pure unit
214201
else do
@@ -253,7 +240,7 @@ downloadS3 name version = do
253240
packageUrl = formatPackageUrl name version
254241

255242
Log.debug $ "Downloading " <> package <> " from " <> packageUrl
256-
response <- Run.liftAff $ withBackoff' $ Affjax.Node.request $ Affjax.Node.defaultRequest
243+
response <- Run.liftAff $ withRetryRequest' $ Affjax.Node.defaultRequest
257244
{ method = Left GET
258245
, responseFormat = ResponseFormat.arrayBuffer
259246
, url = packageUrl
@@ -262,22 +249,35 @@ downloadS3 name version = do
262249
-- TODO: Rely on the metadata to check the size and hash? Or do we not care
263250
-- for registry-internal operations?
264251
case response of
265-
Nothing -> do
252+
Cancelled -> do
266253
Log.error $ "Failed to download " <> package <> " from " <> packageUrl <> " because of a connection timeout."
267254
Except.throw $ "Failed to download " <> package <> " from the storage backend."
268-
Just (Left error) -> do
255+
Failed (AffjaxError error) -> do
269256
Log.error $ "Failed to download " <> package <> " from " <> packageUrl <> " because of an HTTP error: " <> Affjax.Node.printError error
270257
Except.throw $ "Could not download " <> package <> " from the storage backend."
271-
Just (Right { status, body }) | status /= StatusCode 200 -> do
258+
Failed (StatusError { status, body }) -> do
272259
buffer <- Run.liftEffect $ Buffer.fromArrayBuffer body
273260
bodyString <- Run.liftEffect $ Buffer.toString UTF8 (buffer :: Buffer)
274-
Log.error $ "Failed to download " <> package <> " from " <> packageUrl <> " because of a non-200 status code (" <> show status <> ") with body " <> bodyString
261+
Log.error $ "Failed to download " <> package <> " from " <> packageUrl <> " because of a bad status code (" <> show status <> ") with body " <> bodyString
275262
Except.throw $ "Could not download " <> package <> " from the storage backend."
276-
Just (Right { body }) -> do
263+
Succeeded { body } -> do
277264
Log.debug $ "Successfully downloaded " <> package <> " into a buffer."
278265
buffer :: Buffer <- Run.liftEffect $ Buffer.fromArrayBuffer body
279266
pure buffer
280267

268+
withRetryListObjects :: Space -> PackageName -> Aff (Either String (Array String))
269+
withRetryListObjects s3 name = do
270+
let package = PackageName.print name
271+
result <- withRetry (defaultRetry { retryOnFailure = \attempt _ -> attempt < 3 }) do
272+
Aff.attempt (S3.listObjects s3 { prefix: package <> "/" })
273+
pure $ case result of
274+
Cancelled -> do
275+
Left $ "Failed to list S3 objects for " <> package <> " because the process timed out."
276+
Failed error -> do
277+
Left $ "Failed to list S3 objects for " <> package <> " because of an exception: " <> Aff.message error
278+
Succeeded objects ->
279+
pure $ map _.key objects
280+
281281
-- | A key type for the storage cache. Only supports packages identified by
282282
-- | their name and version.
283283
data StorageCache (c :: Type -> Type -> Type) a = Package PackageName Version (c Buffer a)

0 commit comments

Comments
 (0)