git.iko.soy/rowtype-yoga/purescript-yoga-om
on main help sign in
Add Yoga.Om.Retry with recovering and repeating
commit247d662bbb8059979b0995b929e7fab043d071dc authorMark <mark.eibes@gmail.com> dateSat 14 Feb 2026, 00:11 (8 months ago) parente8bbc43
6 files changed+513−2
spago.lock+210−1 spago.yaml+9 src/Yoga/Om.purs+45−1 src/Yoga/Om/Ref.purs+39 src/Yoga/Om/Retry.purs+61 test/Test/Main.purs+149
spago.lock+210−1
@@ -8,6 +8,12 @@
88 {
99 "aff": ">=7.0.0 <8.0.0"
1010 },
11+ {
12+ "aff-retry": ">=2.0.0 <3.0.0"
13+ },
14+ {
15+ "arrays": ">=7.0.0 <8.0.0"
16+ },
1117 {
1218 "avar": ">=5.0.0 <6.0.0"
1319 },
@@ -38,12 +44,18 @@
3844 {
3945 "newtype": ">=5.0.0 <6.0.0"
4046 },
47+ {
48+ "numbers": ">=9.0.0 <10.0.0"
49+ },
4150 {
4251 "parallel": ">=6.0.0 <8.0.0"
4352 },
4453 {
4554 "prelude": ">=6.0.0 <7.0.0"
4655 },
56+ {
57+ "random": ">=6.0.0 <7.0.0"
58+ },
4759 {
4860 "record": ">=4.0.0 <5.0.0"
4961 },
@@ -74,7 +86,14 @@
7486 ]
7587 },
7688 "test": {
77− "dependencies": []
89+ "dependencies": [
90+ {
91+ "refs": ">=6.0.0 <7.0.0"
92+ },
93+ {
94+ "spec": ">=7.0.0 <8.0.0"
95+ }
96+ ]
7897 }
7998 }
8099 },
@@ -599,6 +618,36 @@
599618 "unsafe-coerce"
600619 ]
601620 },
621+ "aff-retry": {
622+ "type": "registry",
623+ "version": "2.0.0",
624+ "integrity": "sha256-rKZNcP4IKSyBFQBVzrCvE0ULoJt0N/uDLLJ0dPotr4k=",
625+ "dependencies": [
626+ "aff",
627+ "arrays",
628+ "datetime",
629+ "effect",
630+ "either",
631+ "exceptions",
632+ "integers",
633+ "maybe",
634+ "newtype",
635+ "numbers",
636+ "prelude",
637+ "random",
638+ "transformers"
639+ ]
640+ },
641+ "ansi": {
642+ "type": "registry",
643+ "version": "7.0.0",
644+ "integrity": "sha256-tOUaXJFzPSfBVAqLI8hc+Tz2M1rg16cXqyHaK/3SmFg=",
645+ "dependencies": [
646+ "foldable-traversable",
647+ "lists",
648+ "prelude"
649+ ]
650+ },
602651 "arrays": {
603652 "type": "registry",
604653 "version": "7.3.0",
@@ -646,6 +695,20 @@
646695 "tuples"
647696 ]
648697 },
698+ "catenable-lists": {
699+ "type": "registry",
700+ "version": "7.0.0",
701+ "integrity": "sha256-7+i/MHBLIRh604iCNT4ENWRWE/wuHIotER4kpdX9Ve0=",
702+ "dependencies": [
703+ "control",
704+ "foldable-traversable",
705+ "lists",
706+ "maybe",
707+ "prelude",
708+ "tuples",
709+ "unfoldable"
710+ ]
711+ },
649712 "console": {
650713 "type": "registry",
651714 "version": "6.1.0",
@@ -794,6 +857,37 @@
794857 "tuples"
795858 ]
796859 },
860+ "fork": {
861+ "type": "registry",
862+ "version": "6.0.0",
863+ "integrity": "sha256-0keBVeZZg1eCZEy/En1CohVW829gQUqKtqBNIRa93/A=",
864+ "dependencies": [
865+ "aff",
866+ "prelude",
867+ "transformers"
868+ ]
869+ },
870+ "free": {
871+ "type": "registry",
872+ "version": "7.1.0",
873+ "integrity": "sha256-tUBInfUpRQEw4u94GWcYW7EYdmSdDHSRn88a+C0eltU=",
874+ "dependencies": [
875+ "catenable-lists",
876+ "control",
877+ "distributive",
878+ "either",
879+ "exists",
880+ "foldable-traversable",
881+ "invariant",
882+ "lazy",
883+ "maybe",
884+ "prelude",
885+ "tailrec",
886+ "transformers",
887+ "tuples",
888+ "unsafe-coerce"
889+ ]
890+ },
797891 "functions": {
798892 "type": "registry",
799893 "version": "6.0.0",
@@ -924,6 +1018,23 @@
9241018 "prelude"
9251019 ]
9261020 },
1021+ "mmorph": {
1022+ "type": "registry",
1023+ "version": "7.0.0",
1024+ "integrity": "sha256-Cfltaq+tfYjuSoSBN/b8cSQZbvZTqIRs6ebQewbIkGs=",
1025+ "dependencies": [
1026+ "bifunctors",
1027+ "either",
1028+ "free",
1029+ "functors",
1030+ "identity",
1031+ "maybe",
1032+ "newtype",
1033+ "prelude",
1034+ "transformers",
1035+ "tuples"
1036+ ]
1037+ },
9271038 "newtype": {
9281039 "type": "registry",
9291040 "version": "5.0.0",
@@ -946,6 +1057,16 @@
9461057 "unfoldable"
9471058 ]
9481059 },
1060+ "now": {
1061+ "type": "registry",
1062+ "version": "6.0.0",
1063+ "integrity": "sha256-mTyG4s8tps3XIf0jjV/lME2/QlwMzrQly2wqfSSUM3o=",
1064+ "dependencies": [
1065+ "datetime",
1066+ "effect",
1067+ "prelude"
1068+ ]
1069+ },
9491070 "numbers": {
9501071 "type": "registry",
9511072 "version": "9.0.1",
@@ -1010,6 +1131,27 @@
10101131 "integrity": "sha256-mdrlJBAgFMh79hNRW39uv7c+BwnnaOrAMmdh7+VhEhs=",
10111132 "dependencies": []
10121133 },
1134+ "pipes": {
1135+ "type": "registry",
1136+ "version": "8.0.0",
1137+ "integrity": "sha256-dtUNpG8BGVr7pu6mQZyAnUAftUHgNS7f0QH3Kw5UTpQ=",
1138+ "dependencies": [
1139+ "aff",
1140+ "control",
1141+ "effect",
1142+ "either",
1143+ "foldable-traversable",
1144+ "identity",
1145+ "lists",
1146+ "maybe",
1147+ "mmorph",
1148+ "newtype",
1149+ "prelude",
1150+ "tailrec",
1151+ "transformers",
1152+ "tuples"
1153+ ]
1154+ },
10131155 "prelude": {
10141156 "type": "registry",
10151157 "version": "6.0.1",
@@ -1031,6 +1173,16 @@
10311173 "tuples"
10321174 ]
10331175 },
1176+ "random": {
1177+ "type": "registry",
1178+ "version": "6.0.0",
1179+ "integrity": "sha256-7sZRo6P9UIiXDZ9s2QL9T8h3wlIwLdQrR4+oZKOijQw=",
1180+ "dependencies": [
1181+ "effect",
1182+ "integers",
1183+ "prelude"
1184+ ]
1185+ },
10341186 "record": {
10351187 "type": "registry",
10361188 "version": "4.0.0",
@@ -1071,6 +1223,40 @@
10711223 "unsafe-coerce"
10721224 ]
10731225 },
1226+ "spec": {
1227+ "type": "registry",
1228+ "version": "7.6.0",
1229+ "integrity": "sha256-CLwN4VGULEM64zwgffT1kQ0BuxAiwjarCW7gSexcQvs=",
1230+ "dependencies": [
1231+ "aff",
1232+ "ansi",
1233+ "arrays",
1234+ "avar",
1235+ "bifunctors",
1236+ "control",
1237+ "datetime",
1238+ "effect",
1239+ "either",
1240+ "exceptions",
1241+ "foldable-traversable",
1242+ "fork",
1243+ "identity",
1244+ "integers",
1245+ "lists",
1246+ "maybe",
1247+ "newtype",
1248+ "now",
1249+ "ordered-collections",
1250+ "parallel",
1251+ "pipes",
1252+ "prelude",
1253+ "refs",
1254+ "strings",
1255+ "tailrec",
1256+ "transformers",
1257+ "tuples"
1258+ ]
1259+ },
10741260 "st": {
10751261 "type": "registry",
10761262 "version": "6.2.0",
@@ -1083,6 +1269,29 @@
10831269 "unsafe-coerce"
10841270 ]
10851271 },
1272+ "strings": {
1273+ "type": "registry",
1274+ "version": "6.0.1",
1275+ "integrity": "sha256-FNto2hoNW5Da6W6crIk77YXMagBvOGHZE1kO7eZ1vLM=",
1276+ "dependencies": [
1277+ "arrays",
1278+ "control",
1279+ "either",
1280+ "enums",
1281+ "foldable-traversable",
1282+ "gen",
1283+ "integers",
1284+ "maybe",
1285+ "newtype",
1286+ "nonempty",
1287+ "partial",
1288+ "prelude",
1289+ "tailrec",
1290+ "tuples",
1291+ "unfoldable",
1292+ "unsafe-coerce"
1293+ ]
1294+ },
10861295 "tailrec": {
10871296 "type": "registry",
10881297 "version": "6.1.0",
spago.yaml+9
@@ -36,3 +36,12 @@ package:
3636 - uncurried-transformers: ">=1.0.0 <2.0.0"
3737 - unsafe-coerce: ">=6.0.0 <7.0.0"
3838 - variant: ">=8.0.0 <9.0.0"
39+ - aff-retry: ">=2.0.0 <3.0.0"
40+ - arrays: ">=7.0.0 <8.0.0"
41+ - numbers: ">=9.0.0 <10.0.0"
42+ - random: ">=6.0.0 <7.0.0"
43+ test:
44+ main: Test.Main
45+ dependencies:
46+ - spec: ">=7.0.0 <8.0.0"
47+ - refs: ">=6.0.0 <7.0.0"
src/Yoga/Om.purs+45−1
@@ -3,6 +3,9 @@
33-- @inline export note arity=2
44-- @inline export fromAff arity=1
55-- @inline export runOm arity=2
6+-- @inline export toOm arity=1
7+-- @inline export onError arity=2
8+-- @inline export mapError arity=2
69module Yoga.Om
710 ( Om(..)
811 , ParOm(..)
@@ -13,6 +16,8 @@ module Yoga.Om
1316 , expandErr
1417 , fromAff
1518 , handleErrors
19+ , mapError
20+ , onError
1621 , handleErrors'
1722 , launchOm
1823 , launchOm_
@@ -29,6 +34,8 @@ module Yoga.Om
2934 , throw
3035 , throwLeftAs
3136 , throwLeftAsM
37+ , class ToOm
38+ , toOm
3239 , unliftAff
3340 , unliftAffFn
3441 , widenCtx
@@ -52,6 +59,7 @@ import Data.Newtype (class Newtype)
5259import Data.Time.Duration (class Duration, fromDuration)
5360import Data.Tuple.Nested ((/\))
5461import Data.Variant (class VariantMatchCases, Variant, case_, match, on, onMatch)
62+import Data.Variant as Variant
5563import Effect (Effect)
5664import Effect.AVar (AVar)
5765import Effect.Aff (Aff, Fiber, ParAff, forkAff, launchAff, supervise)
@@ -60,7 +68,9 @@ import Effect.Aff.AVar as AVar
6068import Effect.Aff.Class (class MonadAff, liftAff)
6169import Effect.Class (class MonadEffect, liftEffect)
6270import Effect.Exception (Error)
63−import Prim.Row (class Nub, class Union)
71+import Data.Symbol (class IsSymbol)
72+import Prim.Row (class Cons, class Nub, class Union)
73+import Type.Proxy (Proxy(..))
6474import Prim.RowList (class RowToList, RowList)
6575import Record (disjointUnion)
6676import Record.Studio (class Keys, shrink)
@@ -218,6 +228,30 @@ handleErrors cases (Om om) = do
218228 err ← liftAff ((\(_ /\ v /\ _) → v) <$> (runRWSET ctx unit om))
219229 err # either (onMatch cases throwError) pure
220230
231+mapError
232+ :: forall @from @to tyIn tyOut ctx errIn errMid errOut a
233+ . IsSymbol from
234+ => IsSymbol to
235+ => Cons from tyIn (exception :: Error | errMid) (exception :: Error | errIn)
236+ => Cons to tyOut (exception :: Error | errMid) (exception :: Error | errOut)
237+ => (tyIn -> tyOut)
238+ -> Om ctx errIn a
239+ -> Om ctx errOut a
240+mapError f = handleErrors' \variant ->
241+ variant # on (Proxy :: Proxy from)
242+ (throwError <<< Variant.inj (Proxy :: Proxy to) <<< f)
243+ (throwError <<< unsafeCoerce)
244+
245+onError
246+ :: forall @label ty ctx errIn errOut a
247+ . IsSymbol label
248+ => Cons label ty (exception :: Error | errOut) (exception :: Error | errIn)
249+ => (ty -> Om ctx errOut a)
250+ -> Om ctx errIn a
251+ -> Om ctx errOut a
252+onError handler = handleErrors' \variant ->
253+ variant # on (Proxy :: Proxy label) handler throwError
254+
221255-- | When you have a function that can throw a subset of the errors of the ones
222256-- | you could throw in a do block, `expand` allows to make them compatible
223257
@@ -347,6 +381,16 @@ runOm ctx errorHandlers (Om app) = do
347381 (_ /\ Right r /\ _) → pure r
348382 (_ /\ Left err /\ _) → err # match errorHandlers
349383
384+class ToOm :: (Type -> Type) -> Constraint
385+class ToOm f where
386+ toOm :: forall ctx err a. f a -> Om ctx err a
387+
388+instance ToOm Effect where
389+ toOm = liftEffect
390+
391+instance ToOm Aff where
392+ toOm = liftAff
393+
350394-- | Manually add some fields to the context of a computation.
351395-- | This function is useful when already inside a `do` block of an `Om`
352396-- | and you want to call a function that requires some `ctx` that you obtained
src/Yoga/Om/Ref.pursnew file+39
@@ -0,0 +1,39 @@
1+-- @inline export new arity=1
2+-- @inline export read arity=1
3+-- @inline export modify' arity=2
4+-- @inline export modify arity=2
5+-- @inline export modify_ arity=2
6+-- @inline export write arity=2
7+module Yoga.Om.Ref
8+ ( module Effect.Ref
9+ , new
10+ , read
11+ , modify'
12+ , modify
13+ , modify_
14+ , write
15+ ) where
16+
17+import Prelude
18+
19+import Effect.Class (class MonadEffect, liftEffect)
20+import Effect.Ref (Ref)
21+import Effect.Ref as Ref
22+
23+new :: forall m s. MonadEffect m => s -> m (Ref s)
24+new = liftEffect <<< Ref.new
25+
26+read :: forall m s. MonadEffect m => Ref s -> m s
27+read = liftEffect <<< Ref.read
28+
29+modify' :: forall m s b. MonadEffect m => (s -> { state :: s, value :: b }) -> Ref s -> m b
30+modify' f = liftEffect <<< Ref.modify' f
31+
32+modify :: forall m s. MonadEffect m => (s -> s) -> Ref s -> m s
33+modify f = liftEffect <<< Ref.modify f
34+
35+modify_ :: forall m s. MonadEffect m => (s -> s) -> Ref s -> m Unit
36+modify_ f = liftEffect <<< Ref.modify_ f
37+
38+write :: forall m s. MonadEffect m => s -> Ref s -> m Unit
39+write s = liftEffect <<< Ref.write s
src/Yoga/Om/Retry.pursnew file+61
@@ -0,0 +1,61 @@
1+module Yoga.Om.Retry
2+ ( recovering
3+ , repeating
4+ , module Effect.Aff.Retry
5+ ) where
6+
7+import Prelude
8+
9+import Control.Monad.Error.Class (catchError, throwError)
10+import Data.Maybe (maybe)
11+import Data.Variant (class VariantMatchCases, onMatch)
12+import Effect.Aff.Retry (RetryPolicyM, RetryStatus, applyAndDelay, defaultRetryStatus)
13+import Effect.Exception (Error)
14+import Prim.Row (class Union)
15+import Prim.RowList (class RowToList, RowList)
16+import Yoga.Om (Om)
17+
18+recovering
19+ :: forall ctx err rest a
20+ (handlersRL :: RowList Type)
21+ (handlers :: Row Type)
22+ (handled :: Row Type)
23+ . RowToList handlers handlersRL
24+ => VariantMatchCases handlersRL handled (Om ctx err Boolean)
25+ => Union handled rest (exception :: Error | err)
26+ => RetryPolicyM (Om ctx err)
27+ -> (RetryStatus -> Record handlers)
28+ -> (RetryStatus -> Om ctx err a)
29+ -> Om ctx err a
30+recovering policy mkChecks action = go defaultRetryStatus
31+ where
32+ go status =
33+ catchError (action status) (handleErr status)
34+
35+ handleErr status variant = do
36+ let shouldRetry = variant # onMatch (mkChecks status) (\_ -> pure false)
37+ ifM shouldRetry
38+ (applyAndDelay policy status >>= maybe (throwError variant) go)
39+ (throwError variant)
40+
41+-- | Repeat a successful action according to a policy while a condition holds.
42+-- | Returns the last result.
43+-- |
44+-- | ```purescript
45+-- | repeating (constantDelay (Milliseconds 1000.0) <> limitRetries 10)
46+-- | (\_ result -> pure (result < threshold))
47+-- | (\_ -> pollSensor)
48+-- | ```
49+repeating
50+ :: forall ctx err a
51+ . RetryPolicyM (Om ctx err)
52+ -> (RetryStatus -> a -> Om ctx err Boolean)
53+ -> (RetryStatus -> Om ctx err a)
54+ -> Om ctx err a
55+repeating policy shouldRepeat action = go defaultRetryStatus
56+ where
57+ go status = do
58+ result <- action status
59+ ifM (shouldRepeat status result)
60+ (applyAndDelay policy status >>= maybe (pure result) go)
61+ (pure result)
test/Test/Main.pursnew file+149
@@ -0,0 +1,149 @@
1+module Test.Main where
2+
3+import Prelude
4+
5+import Effect (Effect)
6+import Effect.Aff (launchAff_)
7+import Effect.Aff.Class (liftAff)
8+import Effect.Class (liftEffect)
9+import Effect.Ref as Ref
10+import Effect.Aff.Retry (RetryStatus(..), limitRetries, constantDelay)
11+import Data.Time.Duration (Milliseconds(..))
12+import Test.Spec (describe, it)
13+import Test.Spec.Assertions (shouldEqual)
14+import Test.Spec.Reporter (consoleReporter)
15+import Test.Spec.Runner (runSpec)
16+import Yoga.Om as Om
17+import Yoga.Om.Retry (recovering, repeating)
18+
19+main :: Effect Unit
20+main = launchAff_ $ runSpec [ consoleReporter ] do
21+ describe "Yoga.Om.Retry - recovering" do
22+
23+ it "retries on matching errors up to the limit" do
24+ attemptsRef <- Ref.new 0 # liftEffect
25+ recovering (constantDelay (Milliseconds 0.0) <> limitRetries 3)
26+ (\_ -> { networkError: \(_ :: String) -> pure true })
27+ (\_ -> do
28+ attempts <- Ref.modify (_ + 1) attemptsRef # liftEffect
29+ Om.throw { networkError: "fail " <> show attempts }
30+ )
31+ # Om.runOm {}
32+ { exception: \_ -> pure unit
33+ , networkError: \_ -> pure unit
34+ }
35+ # liftAff
36+ attempts <- Ref.read attemptsRef # liftEffect
37+ attempts `shouldEqual` 4
38+
39+ it "does not retry on non-matching errors" do
40+ attemptsRef <- Ref.new 0 # liftEffect
41+ recovering (constantDelay (Milliseconds 0.0) <> limitRetries 3)
42+ (\_ -> { networkError: \(_ :: String) -> pure true })
43+ (\_ -> do
44+ Ref.modify_ (_ + 1) attemptsRef # liftEffect
45+ Om.throw { authError: "unauthorized" }
46+ )
47+ # Om.runOm {}
48+ { exception: \_ -> pure unit
49+ , networkError: \_ -> pure unit
50+ , authError: \_ -> pure unit
51+ }
52+ # liftAff
53+ attempts <- Ref.read attemptsRef # liftEffect
54+ attempts `shouldEqual` 1
55+
56+ it "succeeds after transient failures" do
57+ attemptsRef <- Ref.new 0 # liftEffect
58+ result <-
59+ recovering (constantDelay (Milliseconds 0.0) <> limitRetries 5)
60+ (\_ -> { networkError: \(_ :: String) -> pure true })
61+ (\_ -> do
62+ attempts <- Ref.modify (_ + 1) attemptsRef # liftEffect
63+ when (attempts < 3) do
64+ Om.throw { networkError: "transient" }
65+ pure "success"
66+ )
67+ # Om.runOm {}
68+ { exception: \_ -> pure "failed"
69+ , networkError: \_ -> pure "failed"
70+ }
71+ # liftAff
72+ result `shouldEqual` "success"
73+ attempts <- Ref.read attemptsRef # liftEffect
74+ attempts `shouldEqual` 3
75+
76+ it "uses RetryStatus to decide whether to retry" do
77+ statusesRef <- Ref.new [] # liftEffect
78+ recovering (constantDelay (Milliseconds 0.0) <> limitRetries 3)
79+ (\(RetryStatus s) ->
80+ { networkError: \(_ :: Unit) -> do
81+ Ref.modify_ (_ <> [s.iterNumber]) statusesRef # liftEffect
82+ pure true
83+ })
84+ (\_ -> Om.throw { networkError: unit })
85+ # Om.runOm {}
86+ { exception: \_ -> pure unit
87+ , networkError: \_ -> pure unit
88+ }
89+ # liftAff
90+ statuses <- Ref.read statusesRef # liftEffect
91+ statuses `shouldEqual` [0, 1, 2, 3]
92+
93+ it "stops retrying when check returns false" do
94+ attemptsRef <- Ref.new 0 # liftEffect
95+ recovering (constantDelay (Milliseconds 0.0) <> limitRetries 10)
96+ (\(RetryStatus s) ->
97+ { networkError: \(_ :: Unit) -> pure (s.iterNumber < 2) })
98+ (\_ -> do
99+ Ref.modify_ (_ + 1) attemptsRef # liftEffect
100+ Om.throw { networkError: unit }
101+ )
102+ # Om.runOm {}
103+ { exception: \_ -> pure unit
104+ , networkError: \_ -> pure unit
105+ }
106+ # liftAff
107+ attempts <- Ref.read attemptsRef # liftEffect
108+ attempts `shouldEqual` 3
109+
110+ describe "Yoga.Om.Retry - repeating" do
111+
112+ it "repeatings while condition holds" do
113+ counterRef <- Ref.new 0 # liftEffect
114+ result <-
115+ repeating (constantDelay (Milliseconds 0.0) <> limitRetries 10)
116+ (\_ n -> pure (n < 5))
117+ (\_ -> Ref.modify (_ + 1) counterRef # liftEffect)
118+ # Om.runOm {} { exception: \_ -> pure 0 }
119+ # liftAff
120+ result `shouldEqual` 5
121+
122+ it "stops when condition returns false" do
123+ counterRef <- Ref.new 0 # liftEffect
124+ result <-
125+ repeating (constantDelay (Milliseconds 0.0) <> limitRetries 100)
126+ (\_ n -> pure (n < 3))
127+ (\_ -> Ref.modify (_ + 1) counterRef # liftEffect)
128+ # Om.runOm {} { exception: \_ -> pure 0 }
129+ # liftAff
130+ result `shouldEqual` 3
131+
132+ it "stops when policy exhausted" do
133+ counterRef <- Ref.new 0 # liftEffect
134+ result <-
135+ repeating (constantDelay (Milliseconds 0.0) <> limitRetries 3)
136+ (\_ _ -> pure true)
137+ (\_ -> Ref.modify (_ + 1) counterRef # liftEffect)
138+ # Om.runOm {} { exception: \_ -> pure 0 }
139+ # liftAff
140+ result `shouldEqual` 4
141+
142+ it "returns last result" do
143+ result <-
144+ repeating (constantDelay (Milliseconds 0.0) <> limitRetries 2)
145+ (\_ _ -> pure true)
146+ (\(RetryStatus s) -> pure ("attempt-" <> show s.iterNumber))
147+ # Om.runOm {} { exception: \_ -> pure "" }
148+ # liftAff
149+ result `shouldEqual` "attempt-2"