Skip to main content

rust_elm/core/
effect.rs

1use std::any::Any;
2use std::collections::HashMap;
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{Arc, OnceLock};
7
8use parking_lot::Mutex;
9use std::time::Duration;
10
11use crate::bus::BusSender;
12use rust_dependencies::DependencyValues;
13use crate::env::Environment;
14use crate::error::EffectError;
15
16pub type EffectId = u64;
17
18type ErasedTask = Arc<
19    dyn Fn() -> Pin<Box<dyn Future<Output = Result<Box<dyn Any + Send>, EffectError>> + Send>>
20        + Send
21        + Sync,
22>;
23
24type ErasedEnvTask = Arc<
25    dyn Fn(
26            &Environment,
27        ) -> Pin<Box<dyn Future<Output = Result<Box<dyn Any + Send>, EffectError>> + Send>>
28        + Send
29        + Sync,
30>;
31
32type ErasedRun = Arc<
33    dyn Fn(
34            Box<dyn Any + Send>,
35        ) -> Pin<Box<dyn Future<Output = Result<(), EffectError>> + Send>>
36        + Send
37        + Sync,
38>;
39
40static TASK_REGISTRY: OnceLock<Mutex<HashMap<EffectId, ErasedTask>>> = OnceLock::new();
41static ENV_TASK_REGISTRY: OnceLock<Mutex<HashMap<EffectId, ErasedEnvTask>>> = OnceLock::new();
42static RUN_REGISTRY: OnceLock<Mutex<HashMap<EffectId, ErasedRun>>> = OnceLock::new();
43static NEXT_REGISTRY_ID: AtomicU64 = AtomicU64::new(1);
44
45fn task_registry() -> &'static Mutex<HashMap<EffectId, ErasedTask>> {
46    TASK_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
47}
48
49fn env_task_registry() -> &'static Mutex<HashMap<EffectId, ErasedEnvTask>> {
50    ENV_TASK_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
51}
52
53fn run_registry() -> &'static Mutex<HashMap<EffectId, ErasedRun>> {
54    RUN_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
55}
56
57/// Emitter passed to [`Effect::from_run`] closures (UDF `.run { send in … }`).
58pub struct RunSender<M> {
59    pub(crate) tx: BusSender<M>,
60}
61
62impl<M> RunSender<M> {
63    pub fn send(&self, msg: M) {
64        let _ = self.tx.send_blocking(msg);
65    }
66}
67
68pub(crate) fn register_task<M, F>(run: F) -> EffectId
69where
70    M: Send + 'static,
71    F: Fn() -> Pin<Box<dyn Future<Output = Result<M, EffectError>> + Send>> + Send + Sync + 'static,
72{
73    let id = NEXT_REGISTRY_ID.fetch_add(1, Ordering::Relaxed);
74    task_registry().lock().insert(
75        id,
76        Arc::new(move || {
77            let fut = run();
78            Box::pin(async move {
79                fut.await.map(|value| Box::new(value) as Box<dyn Any + Send>)
80            })
81        }),
82    );
83    id
84}
85
86pub(crate) fn register_env_task<M, F>(run: F) -> EffectId
87where
88    M: Send + 'static,
89    F: Fn(&Environment) -> Pin<Box<dyn Future<Output = Result<M, EffectError>> + Send>>
90        + Send
91        + Sync
92        + 'static,
93{
94    let id = NEXT_REGISTRY_ID.fetch_add(1, Ordering::Relaxed);
95    env_task_registry().lock().insert(
96        id,
97        Arc::new(move |env| {
98            let fut = run(env);
99            Box::pin(async move {
100                fut.await.map(|value| Box::new(value) as Box<dyn Any + Send>)
101            })
102        }),
103    );
104    id
105}
106
107pub(crate) fn run_registered_task<M: Send + 'static>(
108    id: EffectId,
109) -> Pin<Box<dyn Future<Output = Result<M, EffectError>> + Send>> {
110    let registry = task_registry().lock();
111    let Some(task) = registry.get(&id).cloned() else {
112        return Box::pin(async {
113            Err(EffectError::TaskFailed("missing registered task"))
114        });
115    };
116    drop(registry);
117    Box::pin(async move {
118        let any = (task)().await?;
119        any.downcast::<M>()
120            .map(|boxed| *boxed)
121            .map_err(|_| EffectError::TaskFailed("task message type mismatch"))
122    })
123}
124
125pub(crate) fn run_registered_env_task<M: Send + 'static>(
126    env: &Environment,
127    id: EffectId,
128) -> Pin<Box<dyn Future<Output = Result<M, EffectError>> + Send>> {
129    let registry = env_task_registry().lock();
130    let Some(task) = registry.get(&id).cloned() else {
131        return Box::pin(async {
132            Err(EffectError::TaskFailed("missing registered env task"))
133        });
134    };
135    let env = env.clone();
136    drop(registry);
137    Box::pin(async move {
138        let any = (task)(&env).await?;
139        any.downcast::<M>()
140            .map(|boxed| *boxed)
141            .map_err(|_| EffectError::TaskFailed("env task message type mismatch"))
142    })
143}
144
145pub(crate) fn register_run<M, F>(run: F) -> EffectId
146where
147    M: Send + 'static,
148    F: Fn(RunSender<M>) -> Pin<Box<dyn Future<Output = Result<(), EffectError>> + Send>>
149        + Send
150        + Sync
151        + 'static,
152{
153    let id = NEXT_REGISTRY_ID.fetch_add(1, Ordering::Relaxed);
154    run_registry().lock().insert(
155        id,
156        Arc::new(move |any: Box<dyn Any + Send>| {
157            match any.downcast::<BusSender<M>>() {
158                Ok(tx) => run(RunSender { tx: *tx }),
159                Err(_) => Box::pin(async {
160                    Err(EffectError::TaskFailed("run sender type mismatch"))
161                }),
162            }
163        }),
164    );
165    id
166}
167
168pub(crate) fn run_registered_run<M: Send + 'static>(
169    id: EffectId,
170    tx: BusSender<M>,
171) -> Pin<Box<dyn Future<Output = Result<(), EffectError>> + Send>> {
172    let registry = run_registry().lock();
173    let Some(run) = registry.get(&id).cloned() else {
174        return Box::pin(async { Err(EffectError::TaskFailed("missing registered run")) });
175    };
176    drop(registry);
177    Box::pin(async move { (run)(Box::new(tx) as Box<dyn Any + Send>).await })
178}
179
180/// Async work descriptor — fn pointer at the description boundary.
181pub type TaskFn<M> = fn() -> Pin<Box<dyn Future<Output = Result<M, EffectError>> + Send>>;
182
183pub type EnvTaskFn<M> =
184    fn(&Environment) -> Pin<Box<dyn Future<Output = Result<M, EffectError>> + Send>>;
185
186/// Pure effect descriptions — interpreted only in `runtime.rs`.
187///
188/// - [`Effect::merge`] / [`Effect::batch`] — run children concurrently (UDF merge).
189/// - [`Effect::concatenate`] / [`Effect::sequence`] — run children in order (UDF concatenate).
190pub enum Effect<M> {
191    None,
192    Task { id: EffectId, run: TaskFn<M> },
193    RegisteredTask { id: EffectId },
194    EnvTask { id: EffectId, run: EnvTaskFn<M> },
195    RegisteredEnvTask { id: EffectId },
196    RegisteredRun { id: EffectId },
197    Batch(Vec<Effect<M>>),
198    Cancellable {
199        id: EffectId,
200        cancel_in_flight: bool,
201        inner: Box<Effect<M>>,
202    },
203    Debounce {
204        id: EffectId,
205        duration: Duration,
206        inner: Box<Effect<M>>,
207    },
208    Throttle {
209        id: EffectId,
210        duration: Duration,
211        latest: bool,
212        inner: Box<Effect<M>>,
213    },
214    Provide {
215        env: Environment,
216        inner: Box<Effect<M>>,
217    },
218    Retry {
219        attempts: u32,
220        inner: Box<Effect<M>>,
221    },
222    RetryBackoff {
223        attempts: u32,
224        delay: Duration,
225        max_delay: Duration,
226        inner: Box<Effect<M>>,
227    },
228    Timeout {
229        duration: Duration,
230        inner: Box<Effect<M>>,
231    },
232    Sequence(Vec<Effect<M>>),
233    Race(Vec<Effect<M>>),
234    Catch {
235        inner: Box<Effect<M>>,
236        recover: fn(EffectError) -> Effect<M>,
237    },
238    Cancel { id: EffectId },
239}
240
241impl<M> Clone for Effect<M> {
242    fn clone(&self) -> Self {
243        match self {
244            Self::None => Self::None,
245            Self::Task { id, run } => Self::Task { id: *id, run: *run },
246            Self::RegisteredTask { id } => Self::RegisteredTask { id: *id },
247            Self::EnvTask { id, run } => Self::EnvTask { id: *id, run: *run },
248            Self::RegisteredEnvTask { id } => Self::RegisteredEnvTask { id: *id },
249            Self::RegisteredRun { id } => Self::RegisteredRun { id: *id },
250            Self::Batch(items) => Self::Batch(items.clone()),
251            Self::Cancellable {
252                id,
253                cancel_in_flight,
254                inner,
255            } => Self::Cancellable {
256                id: *id,
257                cancel_in_flight: *cancel_in_flight,
258                inner: inner.clone(),
259            },
260            Self::Debounce {
261                id,
262                duration,
263                inner,
264            } => Self::Debounce {
265                id: *id,
266                duration: *duration,
267                inner: inner.clone(),
268            },
269            Self::Throttle {
270                id,
271                duration,
272                latest,
273                inner,
274            } => Self::Throttle {
275                id: *id,
276                duration: *duration,
277                latest: *latest,
278                inner: inner.clone(),
279            },
280            Self::Provide { env, inner } => Self::Provide {
281                env: env.clone(),
282                inner: inner.clone(),
283            },
284            Self::Retry { attempts, inner } => Self::Retry {
285                attempts: *attempts,
286                inner: inner.clone(),
287            },
288            Self::RetryBackoff {
289                attempts,
290                delay,
291                max_delay,
292                inner,
293            } => Self::RetryBackoff {
294                attempts: *attempts,
295                delay: *delay,
296                max_delay: *max_delay,
297                inner: inner.clone(),
298            },
299            Self::Timeout { duration, inner } => Self::Timeout {
300                duration: *duration,
301                inner: inner.clone(),
302            },
303            Self::Sequence(items) => Self::Sequence(items.clone()),
304            Self::Race(items) => Self::Race(items.clone()),
305            Self::Catch { inner, recover } => Self::Catch {
306                inner: inner.clone(),
307                recover: *recover,
308            },
309            Self::Cancel { id } => Self::Cancel { id: *id },
310        }
311    }
312}
313
314impl<M> Effect<M> {
315    pub fn none() -> Self {
316        Self::None
317    }
318
319    pub fn task(id: EffectId, run: TaskFn<M>) -> Self {
320        Self::Task { id, run }
321    }
322
323    pub fn from_fn<F>(run: F) -> Self
324    where
325        M: Send + 'static,
326        F: Fn() -> Pin<Box<dyn Future<Output = Result<M, EffectError>> + Send>>
327            + Send
328            + Sync
329            + 'static,
330    {
331        let id = register_task(run);
332        Self::RegisteredTask { id }
333    }
334
335    pub fn env_task(id: EffectId, run: EnvTaskFn<M>) -> Self {
336        Self::EnvTask { id, run }
337    }
338
339    pub fn from_env_fn<F>(run: F) -> Self
340    where
341        M: Send + 'static,
342        F: Fn(&Environment) -> Pin<Box<dyn Future<Output = Result<M, EffectError>> + Send>>
343            + Send
344            + Sync
345            + 'static,
346    {
347        let id = register_env_task(run);
348        Self::RegisteredEnvTask { id }
349    }
350
351    pub fn from_run<F>(run: F) -> Self
352    where
353        M: Send + 'static,
354        F: Fn(RunSender<M>) -> Pin<Box<dyn Future<Output = Result<(), EffectError>> + Send>>
355            + Send
356            + Sync
357            + 'static,
358    {
359        let id = register_run(run);
360        Self::RegisteredRun { id }
361    }
362
363    /// Single-shot async task (alias for [`Effect::task`]).
364    pub fn task_try(id: EffectId, run: TaskFn<M>) -> Self {
365        Self::task(id, run)
366    }
367
368    /// Map a `Result<T, E>` leaf task into `M` via fn pointers (UDF `TaskResult`).
369    pub fn result_task<T, E>(run: TaskFn<Result<T, E>>, on_ok: fn(T) -> M, on_err: fn(E) -> M) -> Self
370    where
371        T: Send + 'static,
372        E: Send + 'static,
373        M: Send + 'static,
374    {
375        Self::from_fn(move || {
376            let fut = run();
377            Box::pin(async move {
378                match fut.await {
379                    Ok(Ok(value)) => Ok(on_ok(value)),
380                    Ok(Err(err)) => Ok(on_err(err)),
381                    Err(effect_err) => Err(effect_err),
382                }
383            })
384        })
385    }
386
387    pub fn batch(effects: impl IntoIterator<Item = Effect<M>>) -> Self {
388        let mut effects: Vec<_> = effects.into_iter().collect();
389        match effects.len() {
390            0 => Self::None,
391            1 => effects.pop().map_or(Self::None, |single| single),
392            _ => Self::Batch(effects),
393        }
394    }
395
396    pub fn merge(effects: impl IntoIterator<Item = Effect<M>>) -> Self {
397        Self::batch(effects)
398    }
399
400    pub fn concatenate(effects: impl IntoIterator<Item = Effect<M>>) -> Self {
401        let effects: Vec<_> = effects.into_iter().collect();
402        if effects.is_empty() {
403            Self::None
404        } else {
405            Self::Sequence(effects)
406        }
407    }
408
409    pub fn map<N>(self, f: fn(M) -> N) -> Effect<N>
410    where
411        M: Send + 'static,
412        N: Send + 'static,
413    {
414        match self {
415            Self::None => Effect::None,
416            Self::Task { run, .. } => Effect::from_fn(move || {
417                let fut = run();
418                Box::pin(async move { fut.await.map(f) })
419            }),
420            Self::RegisteredTask { id } => Effect::from_fn(move || {
421                let fut = run_registered_task::<M>(id);
422                Box::pin(async move { fut.await.map(f) })
423            }),
424            Self::EnvTask { run, .. } => Effect::from_env_fn(move |env| {
425                let fut = run(env);
426                Box::pin(async move { fut.await.map(f) })
427            }),
428            Self::RegisteredEnvTask { id } => Effect::from_env_fn(move |env| {
429                let fut = run_registered_env_task::<M>(env, id);
430                Box::pin(async move { fut.await.map(f) })
431            }),
432            Self::RegisteredRun { id } => Effect::RegisteredRun { id },
433            Self::Batch(items) => Effect::Batch(items.into_iter().map(|e| e.map(f)).collect()),
434            Self::Cancellable {
435                id,
436                cancel_in_flight,
437                inner,
438            } => Effect::Cancellable {
439                id,
440                cancel_in_flight,
441                inner: Box::new(inner.map(f)),
442            },
443            Self::Debounce {
444                id,
445                duration,
446                inner,
447            } => Effect::Debounce {
448                id,
449                duration,
450                inner: Box::new(inner.map(f)),
451            },
452            Self::Throttle {
453                id,
454                duration,
455                latest,
456                inner,
457            } => Effect::Throttle {
458                id,
459                duration,
460                latest,
461                inner: Box::new(inner.map(f)),
462            },
463            Self::Provide { env, inner } => Effect::Provide {
464                env,
465                inner: Box::new(inner.map(f)),
466            },
467            Self::Retry { attempts, inner } => Effect::Retry {
468                attempts,
469                inner: Box::new(inner.map(f)),
470            },
471            Self::RetryBackoff {
472                attempts,
473                delay,
474                max_delay,
475                inner,
476            } => Effect::RetryBackoff {
477                attempts,
478                delay,
479                max_delay,
480                inner: Box::new(inner.map(f)),
481            },
482            Self::Timeout { duration, inner } => Effect::Timeout {
483                duration,
484                inner: Box::new(inner.map(f)),
485            },
486            Self::Sequence(items) => Effect::Sequence(items.into_iter().map(|e| e.map(f)).collect()),
487            Self::Race(items) => Effect::Race(items.into_iter().map(|e| e.map(f)).collect()),
488            Self::Catch { inner, recover: _ } => inner.map(f),
489            Self::Cancel { id } => Effect::Cancel { id },
490        }
491    }
492
493    pub fn cancellable(id: EffectId, inner: Effect<M>) -> Self {
494        Self::cancellable_with(id, true, inner)
495    }
496
497    pub fn cancellable_with(id: EffectId, cancel_in_flight: bool, inner: Effect<M>) -> Self {
498        Self::Cancellable {
499            id,
500            cancel_in_flight,
501            inner: Box::new(inner),
502        }
503    }
504
505    pub fn debounce(id: EffectId, duration: Duration, inner: Effect<M>) -> Self {
506        Self::Debounce {
507            id,
508            duration,
509            inner: Box::new(inner),
510        }
511    }
512
513    pub fn throttle(id: EffectId, duration: Duration, latest: bool, inner: Effect<M>) -> Self {
514        Self::Throttle {
515            id,
516            duration,
517            latest,
518            inner: Box::new(inner),
519        }
520    }
521
522    pub fn cancel(id: EffectId) -> Self {
523        Self::Cancel { id }
524    }
525
526    pub fn provide(env: Environment, inner: Effect<M>) -> Self {
527        Self::Provide {
528            env,
529            inner: Box::new(inner),
530        }
531    }
532
533    /// Scoped single-dependency override (UDF dependency override / `withDependencies`).
534    pub fn provide_dependency<D: Send + Sync + 'static>(value: D, inner: Effect<M>) -> Self {
535        Self::provide(Environment::from_values(DependencyValues::new().with(value)), inner)
536    }
537
538    pub fn retry(attempts: u32, inner: Effect<M>) -> Self {
539        Self::Retry {
540            attempts,
541            inner: Box::new(inner),
542        }
543    }
544
545    /// Re-run `inner` up to `attempts` times with exponential backoff between failures.
546    ///
547    /// The first attempt runs immediately. After each failure, sleeps `delay`, then doubles
548    /// the delay (capped at `max_delay`) before the next attempt.
549    pub fn retry_backoff(
550        attempts: u32,
551        delay: Duration,
552        max_delay: Duration,
553        inner: Effect<M>,
554    ) -> Self {
555        Self::RetryBackoff {
556            attempts,
557            delay,
558            max_delay,
559            inner: Box::new(inner),
560        }
561    }
562
563    pub fn timeout(duration: Duration, inner: Effect<M>) -> Self {
564        Self::Timeout {
565            duration,
566            inner: Box::new(inner),
567        }
568    }
569
570    pub fn sequence(effects: impl IntoIterator<Item = Effect<M>>) -> Self {
571        Self::concatenate(effects)
572    }
573
574    pub fn race(effects: impl IntoIterator<Item = Effect<M>>) -> Self {
575        let effects: Vec<_> = effects.into_iter().collect();
576        if effects.is_empty() {
577            Self::None
578        } else {
579            Self::Race(effects)
580        }
581    }
582
583    pub fn catch(inner: Effect<M>, recover: fn(EffectError) -> Effect<M>) -> Self {
584        Self::Catch {
585            inner: Box::new(inner),
586            recover,
587        }
588    }
589}
590
591impl<M> PartialEq for Effect<M> {
592    fn eq(&self, other: &Self) -> bool {
593        std::mem::discriminant(self) == std::mem::discriminant(other)
594    }
595}
596
597impl<M> std::fmt::Debug for Effect<M> {
598    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
599        match self {
600            Self::None => write!(f, "Effect::None"),
601            Self::Task { id, .. } => write!(f, "Effect::Task({id})"),
602            Self::RegisteredTask { id } => write!(f, "Effect::RegisteredTask({id})"),
603            Self::EnvTask { id, .. } => write!(f, "Effect::EnvTask({id})"),
604            Self::RegisteredEnvTask { id } => write!(f, "Effect::RegisteredEnvTask({id})"),
605            Self::RegisteredRun { id } => write!(f, "Effect::RegisteredRun({id})"),
606            Self::Batch(n) => write!(f, "Effect::Batch({})", n.len()),
607            Self::Cancellable {
608                id,
609                cancel_in_flight,
610                ..
611            } => write!(f, "Effect::Cancellable({id}, in_flight={cancel_in_flight})"),
612            Self::Debounce { id, duration, .. } => {
613                write!(f, "Effect::Debounce({id}, {duration:?})")
614            }
615            Self::Throttle {
616                id,
617                duration,
618                latest,
619                ..
620            } => write!(f, "Effect::Throttle({id}, {duration:?}, latest={latest})"),
621            Self::Provide { .. } => write!(f, "Effect::Provide"),
622            Self::Retry { attempts, .. } => write!(f, "Effect::Retry({attempts})"),
623            Self::RetryBackoff {
624                attempts,
625                delay,
626                max_delay,
627                ..
628            } => write!(
629                f,
630                "Effect::RetryBackoff({attempts}, {delay:?}, max={max_delay:?})"
631            ),
632            Self::Timeout { duration, .. } => write!(f, "Effect::Timeout({duration:?})"),
633            Self::Sequence(n) => write!(f, "Effect::Sequence({})", n.len()),
634            Self::Race(n) => write!(f, "Effect::Race({})", n.len()),
635            Self::Catch { .. } => write!(f, "Effect::Catch"),
636            Self::Cancel { id } => write!(f, "Effect::Cancel({id})"),
637        }
638    }
639}
640
641pub(crate) fn run_leaf<M>(
642    effect: Effect<M>,
643    env: &Environment,
644) -> Pin<Box<dyn Future<Output = Result<M, EffectError>> + Send>>
645where
646    M: Send + 'static,
647{
648    match effect {
649        Effect::Task { run, .. } => run(),
650        Effect::RegisteredTask { id } => run_registered_task(id),
651        Effect::EnvTask { run, .. } => run(env),
652        Effect::RegisteredEnvTask { id } => run_registered_env_task(env, id),
653        Effect::Cancel { .. } => Box::pin(async move {
654            Err(EffectError::Cancelled)
655        }),
656        other => Box::pin(async move {
657            Err(EffectError::Other(format!("non-leaf effect: {other:?}")))
658        }),
659    }
660}
661
662#[cfg(test)]
663#[allow(dead_code, clippy::redundant_closure, clippy::type_complexity)]
664mod tests {
665    use super::*;
666
667    #[test]
668    fn merge_and_concatenate_constructors() {
669        let a = Effect::<i32>::none();
670        let b = Effect::<i32>::none();
671        assert!(matches!(Effect::merge([a, b]), Effect::Batch(_)));
672        assert!(matches!(Effect::concatenate([] as [Effect<i32>; 0]), Effect::None));
673    }
674
675    #[test]
676    fn result_task_maps_ok_and_err() {
677        fn load() -> Pin<Box<dyn Future<Output = Result<Result<i32, &'static str>, EffectError>> + Send>> {
678            Box::pin(async { Ok(Ok(7)) })
679        }
680        fn fail() -> Pin<Box<dyn Future<Output = Result<Result<i32, &'static str>, EffectError>> + Send>> {
681            Box::pin(async { Ok(Err("nope")) })
682        }
683        fn ok(n: i32) -> String {
684            format!("ok:{n}")
685        }
686        fn err(e: &'static str) -> String {
687            format!("err:{e}")
688        }
689
690        let ok_effect = Effect::result_task(load, ok, err);
691        let err_effect = Effect::result_task(fail, ok, err);
692        assert!(matches!(ok_effect, Effect::RegisteredTask { .. }));
693        assert!(matches!(err_effect, Effect::RegisteredTask { .. }));
694    }
695
696    #[test]
697    fn debounce_and_throttle_constructors() {
698        let inner = Effect::<i32>::task(1, || Box::pin(async { Ok(1) }));
699        assert!(matches!(
700            Effect::debounce(9, Duration::from_millis(50), inner.clone()),
701            Effect::Debounce { id: 9, .. }
702        ));
703        assert!(matches!(
704            Effect::throttle(9, Duration::from_millis(50), true, inner),
705            Effect::Throttle {
706                id: 9,
707                latest: true,
708                ..
709            }
710        ));
711    }
712
713    #[test]
714    fn retry_backoff_constructor() {
715        let inner = Effect::<i32>::none();
716        match Effect::retry_backoff(
717            3,
718            Duration::from_millis(50),
719            Duration::from_secs(2),
720            inner,
721        ) {
722            Effect::RetryBackoff {
723                attempts,
724                delay,
725                max_delay,
726                ..
727            } => {
728                assert_eq!(attempts, 3);
729                assert_eq!(delay, Duration::from_millis(50));
730                assert_eq!(max_delay, Duration::from_secs(2));
731            }
732            other => panic!("expected RetryBackoff, got {other:?}"),
733        }
734    }
735}