Skip to content

Commit 1564f07

Browse files
authored
Merge pull request #2540 from htmldoug/retry-after
Retry on Retry-After response header
2 parents 5526ad1 + 6ccc1ea commit 1564f07

File tree

3 files changed

+80
-11
lines changed

3 files changed

+80
-11
lines changed

modules/core/src/main/scala/org/scalasteward/core/application/Context.scala

+6-3
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package org.scalasteward.core.application
1919
import cats.effect._
2020
import cats.effect.implicits._
2121
import cats.syntax.all._
22+
import eu.timepit.refined.auto._
2223
import org.http4s.Uri
2324
import org.http4s.client.Client
2425
import org.http4s.headers.`User-Agent`
@@ -82,14 +83,16 @@ object Context {
8283
_ <- Resource.eval(printBanner(logger))
8384
_ <- Resource.eval(F.delay(System.setProperty("http.agent", userAgentString)))
8485
userAgent <- Resource.eval(F.fromEither(`User-Agent`.parse(userAgentString)))
85-
userAgentMiddleware = ClientConfiguration.setUserAgent[F](userAgent)
86+
middleware = ClientConfiguration
87+
.setUserAgent[F](userAgent)
88+
.andThen(ClientConfiguration.retryAfter[F](maxAttempts = 5))
8689
defaultClient <- ClientConfiguration.build(
8790
ClientConfiguration.BuilderMiddleware.default,
88-
userAgentMiddleware
91+
middleware
8992
)
9093
urlCheckerClient <- ClientConfiguration.build(
9194
ClientConfiguration.disableFollowRedirect[F],
92-
userAgentMiddleware
95+
middleware
9396
)
9497
fileAlg = FileAlg.create(logger, F)
9598
processAlg = ProcessAlg.create(config.processCfg)(logger, F)

modules/core/src/main/scala/org/scalasteward/core/client/ClientConfiguration.scala

+33-1
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,16 @@
1616

1717
package org.scalasteward.core.client
1818

19+
import cats.effect._
20+
import eu.timepit.refined.auto._
21+
import eu.timepit.refined.types.numeric.PosInt
22+
import org.http4s.Response
1923
import org.http4s.client._
2024
import org.http4s.headers.`User-Agent`
21-
import cats.effect._
2225
import org.http4s.okhttp.client.OkHttpBuilder
26+
import org.typelevel.ci._
27+
28+
import scala.concurrent.duration._
2329

2430
object ClientConfiguration {
2531
type BuilderMiddleware[F[_]] = OkHttpBuilder[F] => OkHttpBuilder[F]
@@ -37,6 +43,32 @@ object ClientConfiguration {
3743
Client[F](req => client.run(req.putHeaders(userAgent)))
3844
}
3945

46+
private val RetryAfterStatuses = Set(403, 429, 503)
47+
48+
/**
49+
* @param maxAttempts max number times the HTTP request should be sent
50+
* useful to avoid unexpected cloud provider costs
51+
*/
52+
def retryAfter[F[_]: Temporal](maxAttempts: PosInt = 5): Middleware[F] = { client =>
53+
Client[F] { req =>
54+
def run(attempt: Int = 1): Resource[F, Response[F]] = client
55+
.run(req.putHeaders("X-Attempt" -> attempt.toString))
56+
.flatMap { response =>
57+
val maybeRetried = for {
58+
header <- response.headers.get(ci"Retry-After")
59+
seconds <- header.head.value.toIntOption
60+
if seconds > 0
61+
duration = seconds.seconds
62+
if RetryAfterStatuses.contains(response.status.code)
63+
if attempt < maxAttempts.value
64+
} yield Resource.eval(Temporal[F].sleep(duration)).flatMap(_ => run(attempt + 1))
65+
maybeRetried.getOrElse(Resource.pure(response))
66+
}
67+
68+
run()
69+
}
70+
}
71+
4072
def disableFollowRedirect[F[_]](builder: OkHttpBuilder[F]): OkHttpBuilder[F] =
4173
builder.withOkHttpClient(
4274
builder.okHttpClient.newBuilder().followRedirects(false).build()

modules/core/src/test/scala/org/scalasteward/core/client/ClientConfigurationTest.scala

+41-7
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
package org.scalasteward.core.client
22

3-
import org.http4s.client._
4-
import org.http4s.headers.`User-Agent`
53
import cats.effect._
64
import cats.implicits._
7-
import org.http4s.implicits._
8-
import org.typelevel.ci._
95
import munit.CatsEffectSuite
106
import org.http4s.HttpRoutes
11-
import org.http4s.headers.Location
7+
import org.http4s.client._
8+
import org.http4s.headers.{`Retry-After`, `User-Agent`, Location}
9+
import org.http4s.implicits._
10+
import org.typelevel.ci._
11+
import eu.timepit.refined.auto._
12+
import eu.timepit.refined.types.numeric.PosInt
1213

1314
class ClientConfigurationTest extends CatsEffectSuite {
15+
1416
private val userAgentValue = "my-user-agent"
1517
private val dummyUserAgent =
1618
`User-Agent`.parse(userAgentValue).getOrElse(fail("unable to create user agent"))
@@ -35,12 +37,21 @@ class ClientConfigurationTest extends CatsEffectSuite {
3537

3638
case GET -> Root / "redirected" =>
3739
BadRequest("Got redirected")
40+
41+
case req @ GET -> Root / "retry-after" =>
42+
val maybeAttempt = req.headers.get(ci"X-Attempt").flatMap(_.head.value.toIntOption)
43+
maybeAttempt match {
44+
case Some(attempt) if attempt >= 2 =>
45+
Ok()
46+
case _ =>
47+
Forbidden().map(_.putHeaders(`Retry-After`.fromLong(1)))
48+
}
3849
}
3950
}
4051

4152
test("setUserAgent add a specific user agent to requests") {
42-
import org.http4s.client.dsl.io._
4353
import org.http4s.Method._
54+
import org.http4s.client.dsl.io._
4455

4556
val initialClient = Client.fromHttpApp[IO](routes.orNotFound)
4657
val setUserAgent = ClientConfiguration.setUserAgent[IO](dummyUserAgent)
@@ -59,9 +70,9 @@ class ClientConfigurationTest extends CatsEffectSuite {
5970
}
6071

6172
test("disableFollowRedirect does not follow redirect") {
73+
import org.http4s.Method._
6274
import org.http4s.blaze.server._
6375
import org.http4s.client.dsl.io._
64-
import org.http4s.Method._
6576

6677
val regularClient = ClientConfiguration.build[IO](
6778
ClientConfiguration.BuilderMiddleware.default,
@@ -84,4 +95,27 @@ class ClientConfigurationTest extends CatsEffectSuite {
8495
}
8596
test.assertEquals((400, 302))
8697
}
98+
99+
test("retries on retry-after response header") {
100+
import org.http4s.Method._
101+
import org.http4s.client.dsl.io._
102+
103+
def clientWithMaxAttempts(maxAttempts: PosInt): Client[IO] = {
104+
val initialClient = Client.fromHttpApp[IO](routes.orNotFound)
105+
val retryAfter = ClientConfiguration.retryAfter[IO](maxAttempts)
106+
retryAfter(initialClient)
107+
}
108+
109+
val notEnoughRetries = clientWithMaxAttempts(1)
110+
.run(GET(uri"/retry-after"))
111+
.use(r => r.status.code.pure[IO])
112+
.assertEquals(403)
113+
114+
val exactlyEnoughRetries = clientWithMaxAttempts(2)
115+
.run(GET(uri"/retry-after"))
116+
.use(r => r.status.code.pure[IO])
117+
.assertEquals(200)
118+
119+
notEnoughRetries.flatMap(_ => exactlyEnoughRetries)
120+
}
87121
}

0 commit comments

Comments
 (0)