Skip to content

Commit b36ff3a

Browse files
committed
Retry: introduce ErrorMatcher
1 parent 5dc6986 commit b36ff3a

2 files changed

Lines changed: 232 additions & 88 deletions

File tree

std/shared/src/main/scala/cats/effect/std/Retry.scala

Lines changed: 94 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
package cats.effect.std
1818

19-
import cats.{~>, Monad, Semigroup, Show}
19+
import cats.{~>, Applicative, Monad, Semigroup, Show}
2020
import cats.effect.kernel.GenTemporal
2121
import cats.syntax.apply._
2222
import cats.syntax.flatMap._
@@ -25,14 +25,15 @@ import cats.syntax.functor._
2525
import cats.syntax.monadError._
2626

2727
import scala.concurrent.duration._
28+
import scala.reflect.{classTag, ClassTag}
2829

2930
/**
3031
* Glossary:
3132
* - individual delay - the delay between retries
3233
* - cumulative delay - the total delay accumulated across all retries
3334
*/
3435
sealed trait Retry[F[_], E] {
35-
import Retry.{Decision, Status}
36+
import Retry.{Decision, ErrorMatcher, Status}
3637

3738
/**
3839
* The name of the policy. The name is used for informative purposes.
@@ -66,7 +67,7 @@ sealed trait Retry[F[_], E] {
6667
* {{{
6768
* val timeoutExceptionOnly = Retry
6869
* .exponential[IO, Throwable](1.second)
69-
* .withErrorMatcher { case e: TimeoutException => IO.pure(true) }
70+
* .withErrorMatcher(Retry.ErrorMatcher[IO, Throwable].only[TimeoutException])
7071
*
7172
* // will retry using exponential backoff strategy
7273
* Retry.retry(timeoutExceptionOnly)(IO.raiseError(new TimeoutException("oops")))
@@ -78,7 +79,7 @@ sealed trait Retry[F[_], E] {
7879
* @param matcher
7980
* the matcher to use
8081
*/
81-
def withErrorMatcher(matcher: PartialFunction[E, F[Boolean]]): Retry[F, E]
82+
def withErrorMatcher(matcher: ErrorMatcher[F, E]): Retry[F, E]
8283

8384
/**
8485
* Sets the name for the policy. The name is used for informative purposes.
@@ -366,6 +367,91 @@ object Retry {
366367
private final case class RetryImpl(delay: FiniteDuration) extends Retry
367368
}
368369

370+
/**
371+
* The error matcher decides whether the retry decision should be calculated for the raised
372+
* error or not.
373+
*/
374+
sealed trait ErrorMatcher[F[_], -E] {
375+
def matches(e: E): F[Boolean]
376+
}
377+
378+
object ErrorMatcher {
379+
380+
/**
381+
* Creates an error matcher that matches all errors.
382+
*/
383+
def all[F[_]: Applicative, E]: ErrorMatcher[F, E] =
384+
new Impl[F, E]({ (_: E) => Applicative[F].pure(true) })
385+
386+
/**
387+
* Creates an error matcher using the given `matcher` under the hood.
388+
*
389+
* @param matcher
390+
* the matcher to use
391+
*/
392+
def matches[F[_]: Applicative, E](
393+
matcher: PartialFunction[E, Boolean]
394+
): ErrorMatcher[F, E] =
395+
new Impl[F, E](matcher.andThen(b => Applicative[F].pure(b)))
396+
397+
/**
398+
* Creates an error matcher using the given `matcher` under the hood.
399+
*
400+
* @param matcher
401+
* the matcher to use
402+
*/
403+
def when[F[_]: Applicative, E](
404+
matcher: PartialFunction[E, F[Boolean]]
405+
): ErrorMatcher[F, E] =
406+
new Impl[F, E](matcher)
407+
408+
/**
409+
* A partially-applied constructor.
410+
*
411+
* @example
412+
* {{{
413+
* val onlyTimeoutException = ErrorMatcher[IO, Throwable].only[TimeoutException]
414+
* val allButTimeoutException = ErrorMatcher[IO, Throwable].except[TimeoutException]
415+
* }}}
416+
*/
417+
def apply[F[_], E]: ApplyPartiallyApplied[F, E] =
418+
new ApplyPartiallyApplied(dummy = true)
419+
420+
final class ApplyPartiallyApplied[F[_], E](private val dummy: Boolean) extends AnyVal {
421+
422+
/**
423+
* Creates a new error matcher the matches only errors of type `E1`.
424+
*
425+
* @example
426+
* matches only `TimeoutException` errors:
427+
* {{{
428+
* val matcher = ErrorMatcher[IO, Throwable].only[TimeoutException]
429+
* }}}
430+
*/
431+
def only[E1 <: E: ClassTag](implicit F: Applicative[F]): ErrorMatcher[F, E] =
432+
matches { case _: E1 => true }
433+
434+
/**
435+
* Creates a new error matcher the matches all errors except `E1`.
436+
*
437+
* @example
438+
* matches all errors except the `TimeoutException` error:
439+
* {{{
440+
* val matcher = ErrorMatcher[IO, Throwable].except[TimeoutException]
441+
* }}}
442+
*/
443+
def except[E1 <: E: ClassTag](implicit F: Applicative[F]): ErrorMatcher[F, E] =
444+
matches { case e => !classTag[E1].runtimeClass.isInstance(e) }
445+
}
446+
447+
private final class Impl[F[_]: Applicative, E](
448+
matcher: PartialFunction[E, F[Boolean]]
449+
) extends ErrorMatcher[F, E] {
450+
def matches(e: E): F[Boolean] =
451+
matcher.applyOrElse(e, (_: E) => Applicative[F].pure(false))
452+
}
453+
}
454+
369455
sealed trait BackoffMultiplier extends Product with Serializable
370456
object BackoffMultiplier {
371457

@@ -557,7 +643,7 @@ object Retry {
557643
* the function to decide whether to continue
558644
*/
559645
def named[F[_]: Monad, E](name: String)(decider: (Status, E) => F[Decision]): Retry[F, E] =
560-
RetryImpl(name, decider, { _: E => Monad[F].pure(true) })
646+
RetryImpl(name, decider, ErrorMatcher.all)
561647

562648
implicit def retrySemigroup[F[_], E]: Semigroup[Retry[F, E]] =
563649
(x, y) => x && y
@@ -593,13 +679,11 @@ object Retry {
593679
private final case class RetryImpl[F[_]: Monad, E](
594680
name: String,
595681
decider: (Status, E) => F[Decision],
596-
errorMatcher: PartialFunction[E, F[Boolean]]
682+
errorMatcher: ErrorMatcher[F, E]
597683
) extends Retry[F, E] {
598684

599685
def decide(status: Status, error: E): F[Decision] =
600-
errorMatcher
601-
.applyOrElse(error, (_: E) => Monad[F].pure(false))
602-
.ifM(decider(status, error), Monad[F].pure(Decision.giveUp))
686+
errorMatcher.matches(error).ifM(decider(status, error), Monad[F].pure(Decision.giveUp))
603687

604688
def and(other: Retry[F, E]): Retry[F, E] =
605689
Retry.named(s"($name && ${other.name})") { (status, error) =>
@@ -619,7 +703,7 @@ object Retry {
619703
}
620704
}
621705

622-
def withErrorMatcher(matcher: PartialFunction[E, F[Boolean]]): Retry[F, E] =
706+
def withErrorMatcher(matcher: ErrorMatcher[F, E]): Retry[F, E] =
623707
copy(errorMatcher = matcher)
624708

625709
def withName(name: String): Retry[F, E] =

tests/shared/src/test/scala/cats/effect/std/RetrySpec.scala

Lines changed: 138 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -67,84 +67,6 @@ class RetrySpec extends BaseSpec {
6767
run(policy)(errorIO) must completeAs(expected)
6868
}
6969

70-
"withErrorMatcher - retry only on matched errors" in ticked { implicit ticker =>
71-
val maxRetries = 5
72-
val delay = 1.second
73-
74-
val error = new Error1
75-
val policy =
76-
Retry.constantDelay[IO, Throwable](delay).withMaxRetries(maxRetries).withErrorMatcher {
77-
case _: Error1 => IO.pure(true)
78-
}
79-
80-
val expected = List(
81-
RetryAttempt(Status(0, Duration.Zero), Decision.retry(delay), error),
82-
RetryAttempt(Status(1, 1.second), Decision.retry(delay), error),
83-
RetryAttempt(Status(2, 2.seconds), Decision.retry(delay), error),
84-
RetryAttempt(Status(3, 3.seconds), Decision.retry(delay), error),
85-
RetryAttempt(Status(4, 4.seconds), Decision.retry(delay), error),
86-
RetryAttempt(Status(5, 5.seconds), Decision.giveUp, error)
87-
)
88-
89-
run(policy)(IO.raiseError(error)) must completeAs(expected)
90-
}
91-
92-
"withErrorMatcher - give up on mismatched errors" in ticked { implicit ticker =>
93-
val maxRetries = 5
94-
val delay = 1.second
95-
96-
val policy =
97-
Retry.constantDelay[IO, Throwable](delay).withMaxRetries(maxRetries).withErrorMatcher {
98-
case _: Error1 => IO.pure(true)
99-
}
100-
101-
val expected = List(
102-
RetryAttempt(Status(0, Duration.Zero), Decision.giveUp)
103-
)
104-
105-
run(policy)(errorIO) must completeAs(expected)
106-
}
107-
108-
"withErrorMatcher - keep the last matcher - give up on mismatched" in ticked { implicit t =>
109-
val delay = 1.second
110-
val maxRetries = 1
111-
112-
val policy =
113-
Retry
114-
.constantDelay[IO, Throwable](delay)
115-
.withMaxRetries(maxRetries)
116-
.withErrorMatcher { case _: Error1 => IO.pure(true) }
117-
.withErrorMatcher { case _: Error2 => IO.pure(true) }
118-
119-
val error = new Error1
120-
val expected = List(
121-
RetryAttempt(Status(0, Duration.Zero), Decision.giveUp, error)
122-
)
123-
124-
run(policy)(IO.raiseError(error)) must completeAs(expected)
125-
}
126-
127-
"withErrorMatcher - keep the last matcher" in ticked { implicit ticker =>
128-
val delay = 1.second
129-
val maxRetries = 2
130-
131-
val policy =
132-
Retry
133-
.constantDelay[IO, Throwable](delay)
134-
.withMaxRetries(maxRetries)
135-
.withErrorMatcher { case _: Error1 => IO.pure(true) }
136-
.withErrorMatcher { case _: Error2 => IO.pure(true) }
137-
138-
val error = new Error2
139-
val expected = List(
140-
RetryAttempt(Status(0, Duration.Zero), Decision.retry(delay), error),
141-
RetryAttempt(Status(1, 1.second), Decision.retry(delay), error),
142-
RetryAttempt(Status(2, 2.seconds), Decision.giveUp, error)
143-
)
144-
145-
run(policy)(IO.raiseError(error)) must completeAs(expected)
146-
}
147-
14870
"withCappedDelay - cap the individual delay" in ticked { implicit ticker =>
14971
val maxRetries = 5
15072
val delay = 2.second
@@ -270,6 +192,144 @@ class RetrySpec extends BaseSpec {
270192

271193
}
272194

195+
"Retry#withErrorMatcher" should {
196+
197+
"retry only on matched errors" in ticked { implicit ticker =>
198+
val maxRetries = 2
199+
val delay = 1.second
200+
201+
val error = new Error1
202+
val policy =
203+
Retry
204+
.constantDelay[IO, Throwable](delay)
205+
.withMaxRetries(maxRetries)
206+
.withErrorMatcher(Retry.ErrorMatcher[IO, Throwable].only[Error1])
207+
208+
val expected = List(
209+
RetryAttempt(Status(0, Duration.Zero), Decision.retry(delay), error),
210+
RetryAttempt(Status(1, 1.second), Decision.retry(delay), error),
211+
RetryAttempt(Status(2, 2.seconds), Decision.giveUp, error)
212+
)
213+
214+
run(policy)(IO.raiseError(error)) must completeAs(expected)
215+
}
216+
217+
"give up on mismatched errors" in ticked { implicit ticker =>
218+
val maxRetries = 5
219+
val delay = 1.second
220+
221+
val policy =
222+
Retry
223+
.constantDelay[IO, Throwable](delay)
224+
.withMaxRetries(maxRetries)
225+
.withErrorMatcher(Retry.ErrorMatcher[IO, Throwable].only[Error1])
226+
227+
val expected = List(
228+
RetryAttempt(Status(0, Duration.Zero), Decision.giveUp)
229+
)
230+
231+
run(policy)(errorIO) must completeAs(expected)
232+
}
233+
234+
"keep the last matcher - give up on mismatched" in ticked { implicit ticker =>
235+
val delay = 1.second
236+
val maxRetries = 1
237+
238+
val policy =
239+
Retry
240+
.constantDelay[IO, Throwable](delay)
241+
.withMaxRetries(maxRetries)
242+
.withErrorMatcher(Retry.ErrorMatcher[IO, Throwable].only[Error1])
243+
.withErrorMatcher(Retry.ErrorMatcher[IO, Throwable].only[Error2])
244+
245+
val error = new Error1
246+
val expected = List(
247+
RetryAttempt(Status(0, Duration.Zero), Decision.giveUp, error)
248+
)
249+
250+
run(policy)(IO.raiseError(error)) must completeAs(expected)
251+
}
252+
253+
"keep the last matcher - retry on matching errors" in ticked { implicit ticker =>
254+
val delay = 1.second
255+
val maxRetries = 2
256+
257+
val policy =
258+
Retry
259+
.constantDelay[IO, Throwable](delay)
260+
.withMaxRetries(maxRetries)
261+
.withErrorMatcher(Retry.ErrorMatcher[IO, Throwable].only[Error1])
262+
.withErrorMatcher(Retry.ErrorMatcher[IO, Throwable].only[Error2])
263+
264+
val error = new Error2
265+
val expected = List(
266+
RetryAttempt(Status(0, Duration.Zero), Decision.retry(delay), error),
267+
RetryAttempt(Status(1, 1.second), Decision.retry(delay), error),
268+
RetryAttempt(Status(2, 2.seconds), Decision.giveUp, error)
269+
)
270+
271+
run(policy)(IO.raiseError(error)) must completeAs(expected)
272+
}
273+
274+
"ErrorMatcher.except - give up on 'excepted' errors" in ticked { implicit ticker =>
275+
val maxRetries = 2
276+
val delay = 1.second
277+
278+
val error = new Error1
279+
val policy =
280+
Retry
281+
.constantDelay[IO, Throwable](delay)
282+
.withMaxRetries(maxRetries)
283+
.withErrorMatcher(Retry.ErrorMatcher[IO, Throwable].except[Error1])
284+
285+
val expected = List(
286+
RetryAttempt(Status(0, Duration.Zero), Decision.giveUp, error)
287+
)
288+
289+
run(policy)(IO.raiseError(error)) must completeAs(expected)
290+
}
291+
292+
"ErrorMatcher.except - give up on subtypes" in ticked { implicit ticker =>
293+
val maxRetries = 2
294+
val delay = 1.second
295+
296+
val error = new Error1
297+
val policy =
298+
Retry
299+
.constantDelay[IO, Throwable](delay)
300+
.withMaxRetries(maxRetries)
301+
.withErrorMatcher(Retry.ErrorMatcher[IO, Throwable].except[RuntimeException])
302+
303+
val expected = List(
304+
RetryAttempt(Status(0, Duration.Zero), Decision.giveUp, error)
305+
)
306+
307+
run(policy)(IO.raiseError(error)) must completeAs(expected)
308+
}
309+
310+
"ErrorMatcher.except - recover on all errors but the 'excepted' one" in ticked {
311+
implicit ticker =>
312+
val maxRetries = 2
313+
val delay = 1.second
314+
315+
val error = new Error2
316+
val policy =
317+
Retry
318+
.constantDelay[IO, Throwable](delay)
319+
.withMaxRetries(maxRetries)
320+
.withErrorMatcher(Retry.ErrorMatcher[IO, Throwable].except[Error1])
321+
322+
val expected = List(
323+
RetryAttempt(Status(0, Duration.Zero), Decision.retry(delay), error),
324+
RetryAttempt(Status(1, 1.second), Decision.retry(delay), error),
325+
RetryAttempt(Status(2, 2.seconds), Decision.giveUp, error)
326+
)
327+
328+
run(policy)(IO.raiseError(error)) must completeAs(expected)
329+
}
330+
331+
}
332+
273333
"Retry.exponentialBackoff" should {
274334
// it's not random :)
275335
val RandomNextDouble = 1.0

0 commit comments

Comments
 (0)