Build: The Space-Track Source
Maps to: Tasks 2.1 (token-bucket limiter) + 2.2 (session auth + the Space-Track source). Kind: Build — spec and test names only. No implementation.
Objective
Build the first authenticated, rate-limited source. Two files:
limiter.rs— the hand-rolledTokenBucketfrom the rate-limiting concept: burst up tocapacity, refill at a fixed rate,acquiresleeps until a token is free.spacetrack.rs—Credentials(fromnewor the environment),SpaceTrackSource(E — log in, keep the cookie, run the CDM query, rate-limited), andSpaceTrackTransform(T — Space-Track CDM JSON intoRow::Conjunction).
By the end, SpaceTrackSource implements Source and SpaceTrackTransform implements Transform — the same two traits CelesTrak implemented, now with a login handshake and a limiter in front. Every test hits a wiremock server or runs under paused tokio time; nothing touches the live network or a real Space-Track account.
Scaffold
Create: crates/etl-sources/src/limiter.rs and crates/etl-sources/src/spacetrack.rs; declare both modules in lib.rs.
Dependencies: none new — reqwest, async-trait, chrono, serde/serde_json, tokio, and the dev-only wiremock + pretty_assertions were all declared when you created etl-sources for CelesTrak. The paused-clock tests need tokio's test-util feature, which the crate's [dev-dependencies] already enables (tokio = { workspace = true, features = ["test-util"] }).
reqwest,wiremock,chrono, andtokiotime are not on the Rust playground. Build and test in the workspace withcargo test -p etl-sources.
Expected result: cargo test -p etl-sources → all limiter and spacetrack tests green (alongside the CelesTrak tests from Part II).
Part A — the token bucket (limiter.rs)
The rate-limiting concept page is the reference; this is the interface to build.
TokenBucket — a #[derive(Clone)] struct with fields capacity: f64, refill_per_sec: f64, and state: Arc<Mutex<State>>, where State { tokens: f64, last: Instant }. The Arc<Mutex<…>> is what lets a cloned bucket, shared across tasks, refer to one shared pool of tokens.
new(capacity: u32, refill_per_sec: f64) -> Self— start the bucket full (tokens = capacity) withlast = Instant::now(). (Theu32capacity is stored asf64.)async fn acquire(&self)— the loop from the concept page: lock the state, refill byelapsed × refill_per_seccapped atcapacity, and if at least one token is available, spend it and return; otherwise compute the wait for one token to refill, drop the lock,tokio::time::sleep(wait).await, and loop.
The rule that matters (from the concept page): compute the wait inside the lock, then release the guard before the .await. Holding a MutexGuard across tokio::time::sleep would block every other caller for the whole sleep.
Test names to hit (both #[tokio::test(start_paused = true)] so virtual time fast-forwards through the waits):
bucket_allows_burst_up_to_capacity—TokenBucket::new(3, 0.5);acquire().awaitthree times; assertstart.elapsed()is under ~50 ms. A full bucket serves the burst with no real waiting.bucket_blocks_when_empty_then_refills—TokenBucket::new(1, 1.0);acquire().awaittwice. The second call finds the bucket empty and must wait ~1 s for a refill; assertstart.elapsed() >= 900 ms. Under the paused clock this passes in microseconds of wall time.
bucket_blocks_when_empty_then_refills, the bucket has capacity 1 and refills 1 token/sec. Before running, say what start.elapsed() will report in virtual time, and what it would cost in real wall-clock time. If your mental model says "about a second of real time," re-read the paused-clock section — the whole point is that it does not.
Part B — Credentials + SpaceTrackSource (E) (spacetrack.rs)
Credentials — a struct with public user: String and pass: String.
new(user: impl Into<String>, pass: impl Into<String>) -> Self.from_env() -> Result<Self, EtlError>— readSPACETRACK_USERandSPACETRACK_PASS; if either is missing, returnEtlError::Auth("… not set"). (Missing credentials are permanent — the retry loop must not retry them.)
SpaceTrackSource — fields base_url: String, creds: Credentials, limiter: TokenBucket; constructor new(base_url, creds, limiter). It implements Source:
name(&self)returns"spacetrack".extractperforms the handshake from the concept page, spending a token before each request:self.limiter.acquire().await, then POST{base}/ajaxauth/loginwith the credentials as form fieldsidentityandpassword(trim a trailing/offbase_url). A transport error maps toEtlError::Network.- On a non-success login status, return
classify_status(status, headers)— so401/403becomeEtlError::Auth(permanent) while5xx/429stay transient. - Read the
SET_COOKIEheader, keep only the first;-separated segment; if absent,EtlError::Auth("login returned no session cookie"). self.limiter.acquire().await, then GET{base}/basicspacedata/query/class/cdm_public/orderby/TCA/format/jsonwith that string in theCOOKIEheader. Non-success status →classify_status.- On success, read the body text into one
RawRecord { source: "spacetrack", payload, fetched_at: Utc::now() }; return a one-elementVec.
Test names to hit (wiremock):
login_then_query_uses_cookie— the core E test. Mount two mocks: aPOST /ajaxauth/loginreturning200withset-cookie: chocolatechip=abc123; Path=/, and aGETon the CDM query path guarded byheader_exists("cookie"), returning200with a CDM JSON body, marked.expect(1). PointSpaceTrackSource::new(server.uri(), Credentials::new("u", "p"), bucket())at it; assert oneRawRecord, and that its payload contains a known NORAD id. The.expect(1)verifies on drop that the cookie-guarded query was actually hit — proof the cookie was forwarded.login_failure_is_auth_error— mount only aPOST /ajaxauth/loginreturning401; assertextract().awaitisErr(EtlError::Auth(_)).
header_exists("cookie"). If your extract forgets to attach the COOKIE header — or attaches it under the wrong name — the query mock matches nothing, wiremock answers 404, and classify_status turns that into EtlError::Permanent. The test then fails with a "permanent 404" that looks like the server rejected you, when really the mock was never hit because the cookie never arrived. When login_then_query_uses_cookie fails on an unexpected 404, suspect the forwarded cookie before you suspect the source.
Part C — SpaceTrackTransform (T) (spacetrack.rs)
SpaceTrackTransform — a unit struct implementing Transform. Space-Track serializes a CDM (Conjunction Data Message) as JSON where every number arrives as a string. Deserialize into a private RawCdm with #[serde(rename = "…")] on each field:
TCA -> tca: String (RFC-3339-ish, e.g. "2026-07-20T12:00:00.000000")
MIN_RNG -> min_rng_m: String (miss distance in METERS, as a string)
PC -> pc: Option<String> (collision probability; may be absent/empty)
SAT_1_ID -> sat1: String
SAT_2_ID -> sat2: String
transform parses the payload as Vec<RawCdm> (a parse failure → EtlError::Parse), then for each CDM builds a Conjunction:
tca: parse withNaiveDateTime::parse_from_str(&c.tca, "%Y-%m-%dT%H:%M:%S%.f")then.and_utc(); a bad value →EtlError::Parse.miss_distance_km: parsemin_rng_masf64and divide by 1000.0 (meters → kilometers); unparseable →EtlError::Parse.collision_probability: ifpcis present and non-empty, parse it asf64; otherwise default to0.0.primary/secondary:c.sat1.parse()andc.sat2.parse()intoNoradId— the newtype'sFromStrreturnsEtlError::Parseon junk, so?lifts it straight into the taxonomy.id:format!("{}_{}_{}", primary.0, secondary.0, tca.format("%Y%m%dT%H%M%S")). (This is a readable composite key; Part IV replaces it with a content-addressed hash.)
Push each as Row::Conjunction(...).
Test name to hit:
transform_parses_cdm_to_conjunction— handSpaceTrackTransformaRawRecordwhose payload is a one-element CDM array (MIN_RNG"550.5",PC"0.0001", sats"25544"/"48274"). Assert one row, aRow::Conjunctionwithprimary == NoradId(25544),secondary == NoradId(48274),miss_distance_km ≈ 0.5505(note the ÷1000), andcollision_probability ≈ 0.0001.
MIN_RNG is "550.5", not 550.5 — deserialize into String and parse yourself, or serde rejects the document. Second, MIN_RNG is in meters while Conjunction.miss_distance_km is kilometers; forget the / 1000.0 and every miss distance is off by a factor of a thousand — a test that miss_distance_km ≈ 0.5505 (not 550.5) is what catches it.
The build loop (you drive)
- Token bucket first. Write
bucket_allows_burst_up_to_capacity, predict "no wait," implementnew+ theacquireloop, run green. bucket_blocks_when_empty_then_refills. Predict the virtual elapsed time before running; confirm the paused clock makes it instant in real time. If it actually hangs, you are almost certainly holding the lock across the.await— release the guard before sleeping.Credentials::from_env. Small and pure; a missing var isEtlError::Auth.login_then_query_uses_cookie. Stand up both mocks; predict what happens if you drop theCOOKIEheader (silent 404 via theheader_existsguard), then confirm. Get the happy path green.login_failure_is_auth_error. One mock returning401; assertEtlError::Auth.transform_parses_cdm_to_conjunction. Build theRawCdmstructs, wire the meters→km and string→number conversions. If the miss distance is 1000× too big, you skipped the divide.- Run green, commit.
Done when
cargo test -p etl-sources is green. You now have a second concrete Source/Transform pair — authenticated, rate-limited, emitting the Conjunction keystone — behind the exact same traits as CelesTrak. The orchestrator will not be able to tell them apart, which is the entire point. Next you build the ledger that lets a run remember it happened.