Build: Retry Middleware + the Idempotent Sink
Maps to: Tasks 3.1 + 3.2. Kind: Build — spec and test names only. No implementation.
Objective
Build the two etl-core seams the paginated source depends on: with_retry + RetryPolicy (retry transient failures with capped exponential backoff, honoring Retry-After) and the content-addressing machinery — content_id, Row::content_key, and the IdempotentSink decorator that makes re-running the pipeline write each row at most once.
Both live in etl-core (not etl-sources): they are seams every other crate consumes, so they belong in the crate the dependency arrow points toward.
Scaffold
Create: crates/etl-core/src/retry.rs and crates/etl-core/src/content.rs; add pub mod retry; / pub mod content; and re-exports to crates/etl-core/src/lib.rs:
pub use content::{content_id, IdempotentSink};
pub use retry::{with_retry, RetryPolicy};
Dependencies (etl-core): add sha2 (content ids) and ensure tokio (with time — for sleep and, in tests, test-util) and async-trait are present. serde_json is already there for Row's canonical form.
sha2andtokioare not playground-safe; build and test in the workspace withcargo test -p etl-core retryandcargo test -p etl-core content.
Expected result: cargo test -p etl-core retry and cargo test -p etl-core content → all tests green.
Part A — with_retry + RetryPolicy (retry.rs)
RetryPolicy — #[derive(Clone, Copy, Debug)] with fields max_attempts: u32, base_delay: Duration, max_delay: Duration. Default = { max_attempts: 4, base_delay: 200ms, max_delay: 10s }.
with_retry — the signature and behavior:
pub async fn with_retry<F, Fut, T>(policy: RetryPolicy, op: F) -> Result<T, EtlError>
where F: FnMut() -> Fut, Fut: Future<Output = Result<T, EtlError>>
- Loop, calling
op().awaiteach iteration. OnOk, return it. - On
Err, incrementattempt. Ifattempt >= policy.max_attemptsor!err.is_transient(), return the error immediately. - Otherwise compute the backoff —
err.retry_after()if the error carries one (a429'sRetry-After), else capped exponential(base_delay * 2^(attempt-1)).min(max_delay)—sleepit, and loop.
op is FnMut returning a fresh Future each call because retrying means re-running it.
Test names to hit (all #[tokio::test(start_paused = true)], using an AtomicU32 to count op calls):
retries_transient_then_succeeds—opreturnsNetworkon the first two calls, thenOk(42); assert the result is42andopran exactly 3 times.gives_up_on_permanent_immediately—opalways returnsParse; assert the error isParseandopran exactly 1 time (no retry on a permanent error).honors_retry_after—opreturnsRateLimited { retry_after_secs: 5 }once, thenOk; capturetokio::time::Instant::now()before and assertstart.elapsed() >= 5s(the server'sRetry-Afterbeats the small base backoff — provable in microseconds because the clock is paused).
Part B — content addressing + IdempotentSink (content.rs)
content_id — pub fn content_id(kind: &str, canonical: &str) -> String: SHA-256 of kind bytes, then a single 0x00 byte, then canonical bytes, formatted as lowercase hex. The kind tag prevents cross-entity id collisions; the 0x00 separator removes boundary ambiguity between kind and canonical.
Row::content_key — pub fn content_key(&self) -> Result<String, EtlError>: serialize self to JSON (serde_json::to_string, error → EtlError::Parse), then content_id("row", &canonical). Fallible on purpose — never unwrap_or_default, which would collapse distinct un-serializable rows onto one shared key and silently drop them as duplicates.
IdempotentSink<S: Sink> — fields inner: S and seen: Mutex<HashSet<String>>. Two constructors: new(inner) (empty seen-set) and with_seen(inner, keys: impl IntoIterator<Item = String>) (pre-loaded, for cross-process re-runs). It implements Sink: under the lock, insert each row's content_key()? into seen; the rows whose key was newly inserted are "fresh." If none are fresh, return Ok(0); otherwise forward only the fresh rows to inner.load and return its count.
Test names to hit:
same_content_hashes_stable_distinct_content_differs—content_id("row","x") == content_id("row","x");!= content_id("row","y"); andcontent_id("a","x") != content_id("b","x")(thekindtag matters).content_key_is_stable_and_distinguishes_rows— two equalRow::Launches have equal keys; a row with a changed field has a different key.rerun_writes_zero_new_rows— wrap a counting sink;loadtwo rows returns2; loading the identical two again returns0.distinct_rows_all_written— three distinct rows through the sink return3.
Concepts exercised
- Capped exponential backoff, transient-only retry,
Retry-Afterprecedence (Retry with Backoff). - Deterministic timing tests via tokio's paused clock.
- Content-addressed ids and the decorator-
Sinkidempotency pattern (Idempotent, Content-Addressed Writes).
The build loop (you drive)
content_idfirst — pure and tiny. Writesame_content_hashes_stable_distinct_content_differs, implement, run green.content_key. Writecontent_key_is_stable_and_distinguishes_rows. Predict: why must the return type beResult, notString?IdempotentSink. Writererun_writes_zero_new_rowsagainst a counting stub sink; implement the decorator; thendistinct_rows_all_written.with_retry. Writegives_up_on_permanent_immediately(proves theis_transientgate) beforeretries_transient_then_succeeds. Addhonors_retry_afterlast, understart_paused = true.- Run green, commit.
Done when
cargo test -p etl-core retry content is green. with_retry and IdempotentSink are now available to every source — SpaceDevsSource already wraps each page fetch in with_retry, and any pipeline can slip an IdempotentSink in front of its JsonlSink to make re-runs safe. These are the seams the orchestration and retrieval arcs will spend.