diff --git a/.hermes/plans/mytheclipse-round8-spec.md b/.hermes/plans/mytheclipse-round8-spec.md index ffe5ec9..228d5a3 100644 --- a/.hermes/plans/mytheclipse-round8-spec.md +++ b/.hermes/plans/mytheclipse-round8-spec.md @@ -1,18 +1,23 @@ -# Implementation Spec: Round 8 — COMPLETE +# Round 8 — COMPLETE ## New Feature - -### ResilientHttpClient (mytheclipse-http, resilience) -File: `crates/mytheclipse-http/src/resilient_client.rs` -- `ResilientHttpClient` — reqwest Client + ServiceBuilder pipeline (retry/circuit/timeout) -- `ResilientClientConfig` — timeout, max_attempts, rate, circuit_breaker -- `send(req)` / `get(url)` / `post(url, body)` — all run through ServiceBuilder::run -- Error type `RunError>` (HttpError alias) -- 2 tests +### ResilientHttpClient (mytheclipse-http, resilience feature) +- File: `crates/mytheclipse-http/src/resilient_client.rs` +- `ResilientClientConfig { timeout, max_attempts, rate_per_sec, rate_burst, circuit_breaker }` +- `ResilientHttpClient::new(config)` builds `ServiceBuilder` pipeline +- `send(req)`, `get(url)`, `post(url, body)` — all run through `ServiceBuilder::run` +- Error type `RunError>` +- Feature: `resilience = ["dep:reqwest", "dep:tokio", "dep:mytheclipse"]` +- mytheclipse dep now `features=["full"]` (was observability) +- 2 tests (config defaults + build) ## Modified -- http/Cargo.toml: +resilience feature, mytheclipse dep features=full -- http/lib.rs: +module +export -- core/lib.rs: pub use RunError, ServiceConfig from service_builder +- http/Cargo.toml — resilience feature + mytheclipse full features +- http/lib.rs — module + re-export +- core/lib.rs — pub use RunError, ServiceConfig (needed by http crate) +- error.rs — RateLimit(String) variant (queue crate, round 6 carryover) -Build: exit 0. Tests: 0 FAILED. Clippy: 0 new warnings. +## Build: exit 0. Tests: 0 FAILED. Clippy: 0 new warnings. + +## Skill created: rust-workspace-abstractions (software-development) +Captures feature-gating, cross-crate deps, trait/async patterns, ownership patterns, error types, testing conventions for workspace abstraction authoring. diff --git a/.hermes/plans/mytheclipse-round9-spec.md b/.hermes/plans/mytheclipse-round9-spec.md new file mode 100644 index 0000000..4ae9ca8 --- /dev/null +++ b/.hermes/plans/mytheclipse-round9-spec.md @@ -0,0 +1,16 @@ +# Implementation Spec: Round 9 — COMPLETE + +## New Feature + +### RetryExt (mytheclipse-core, resiliency) +File: `crates/mytheclipse/src/retry_ext.rs` +- `RetryExt` trait — `.retry(config, predicate, self_fn)` extension pada Future> +- Delegasi ke `crate::retry::retry` +- Non-Send Pin> return (single-threaded test OK) +- 1 test (retries_then_succeeds) + +## Files +- new: retry_ext.rs +- core/lib.rs: +module +pub use RetryExt + +Build: exit 0. Tests: 0 FAILED. Clippy: 0 new warnings. diff --git a/crates/mytheclipse/src/lib.rs b/crates/mytheclipse/src/lib.rs index 44675a7..967d841 100644 --- a/crates/mytheclipse/src/lib.rs +++ b/crates/mytheclipse/src/lib.rs @@ -34,6 +34,10 @@ pub mod bg; #[cfg(feature = "resiliency")] pub mod retry; #[cfg(feature = "resiliency")] +pub mod retry_ext; +#[cfg(feature = "resiliency")] +pub use retry_ext::RetryExt; +#[cfg(feature = "resiliency")] pub mod circuit_breaker; #[cfg(feature = "resiliency")] pub mod timeout; diff --git a/crates/mytheclipse/src/retry_ext.rs b/crates/mytheclipse/src/retry_ext.rs new file mode 100644 index 0000000..f5cc7c8 --- /dev/null +++ b/crates/mytheclipse/src/retry_ext.rs @@ -0,0 +1,72 @@ +//! Ergonomic retry extension trait (feature `resiliency`). +//! +//! [`RetryExt`] extends any [`std::future::Future>`] +//! with a `.retry()` method that delegates to [`crate::retry::retry`]. + +use std::future::Future; +use std::pin::Pin; + +use crate::retry::{retry, RetryConfig, RetryError}; + +/// Extension trait adding ergonomic `.retry()` to any fallible future. +pub trait RetryExt: Future> + Sized + 'static +where + E: std::fmt::Debug + Send + 'static, + T: Send + 'static, +{ + /// Retries the future's result via a reconstructive `self_fn` closure, + /// delegating to [`crate::retry::retry`]. The original future is consumed + /// on the first attempt; subsequent attempts use `self_fn()`. + fn retry( + self, + config: RetryConfig, + predicate: P, + self_fn: F, + ) -> Pin>>>> + where + F: FnMut() -> Fut + 'static, + Fut: Future> + 'static, + P: Fn(&E) -> bool + 'static, + { + Box::pin(async move { + let _ = self.await; + retry(config, predicate, self_fn).await + }) + } +} + +impl RetryExt for Fut +where + Fut: Future> + Send + 'static, + E: std::fmt::Debug + Send + 'static, + T: Send + 'static, +{ +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use std::sync::Arc; + use std::sync::atomic::{AtomicU32, Ordering}; + + #[tokio::test] + async fn retry_ext_retries_then_succeeds() { + let attempts = Arc::new(AtomicU32::new(0)); + let a = Arc::clone(&attempts); + let cfg = RetryConfig { max_attempts: 3, base_delay: Duration::from_millis(1), ..RetryConfig::default() }; + + let op = move || { + let a = Arc::clone(&a); + async move { + let n = a.fetch_add(1, Ordering::SeqCst); + if n < 2 { Err::<(), String>("transient".into()) } else { Ok(()) } + } + }; + + let fut = async { Err::<(), String>("first".into()) }; + let result = fut.retry(cfg, |_: &String| true, op).await; + assert!(result.is_ok()); + assert_eq!(attempts.load(Ordering::SeqCst), 3); + } +}