Make the runtime keep the contracts the tests now hold it to

Merged
JE
2mo ago
+1652-199

Card: hey-sdk Rust: runtime and consumer contract tests

Based on origin/main; independent of #165 (tracing), which touches the same attempt() loop — whichever lands second rebases.

Contracts, and what the runtime had to change to keep them

  • Operation deadline ≠ transport timeout. New ClientBuilder::operation_timeout(Duration): bounds the whole call — gate wait, credentials, every attempt, every backoff, the refresh resend, the body read. Past it the work is dropped where it stands, which is what makes the existing Running guard report the end and the resilience layer give back its permit; the caller gets a retryable Network error naming the limit. Unset by default (no behaviour change). Tests in tests/deadline.rs: cut off while the transport would still wait, cut off during a 5 s backoff after one request, cut off while the token provider is slow before anything is sent, a cut-off call ending for the hooks and freeing the bulkhead's one permit for the next call, zero refused.
  • Concurrent 401s coalesce into one refresh. Shared gains a refresh count and a refresh mutex; a request remembers the count it was signed under, and a 401 answered after someone else refreshed is resent on the new credentials without refreshing again. A refresh that fails leaves the count alone so the next 401 asks again. Tests in tests/credential_refresh.rs over an in-test HttpClient (reqwest's pool holds later requests to a new host until the first connection settles its protocol, which would sign them after the refresh and prove nothing): ten concurrent 401s → one refresh, twenty requests, ten on the new token; a call signed before the refresh does not burn a second rotating token; a failed refresh leaves every caller with its 401.
  • Pagination completeness and an explicit cap signal. get_all, get_all_with_limit, follow_pagination and each_page end as Error::pagination_capped(max_pages) (Usage) when they reach the limit with a next page still named, instead of returning a shorter answer that looks complete; a walk that ends inside the limit, or a visitor that stops, is not a truncation. Tests: cap signalled, ends-inside-limit complete, empty page with a cursor followed, repeating cursor ends at the limit, failing page ends the walk with its status and request id. The two existing "stops at the limit" tests flip to expect the signal.
  • Diagnostics survive a decode failure. Response::json keeps the status and x-request-id; send/send_optional/send_page prefix the operation. Test in tests/errors.rs.
  • Compile-time guarantees. tests/guarantees.rs: Client: Clone + Send + Sync, Error/Response/Page/the trait objects Send + Sync, and every kind of call (generated read/write, raw, get_all, execute, for_account, next_page, each_page) a Send future — checked in a never-run function, so nothing needs a live server.
  • Redaction in anything formatted. tests/redaction.rs: the token provider, the OAuth requests and Token, and credential headers print no secret; an Operation prints its body as content type and length, never bytes (custom Debug for Body); a transport failure names no URL — this one found a real leak: reqwest 0.13 writes the full URL, query included, into its error text, which became the hint and the Debug source. Fixed with reqwest::Error::without_url() at the three mapping sites.
  • External consumer build. make rs-consumer-check (scripts/rs-consumer-check): cargo package, unpack the .crate into a temp dir, build a throwaway crate against it by path — its own [workspace], no dev-dependencies, fresh resolution — with the default features and with default-features = false. Passes locally for 0.30.0. CI step: the CI-hardening card owns test.yml; the step to add is make rs-consumer-check in the Rust test job after rs-check (noted on that card).
  • TokenProvider is now implemented for Arc<P>, as Hooks already is, so a provider can be shared with the client and watched by the application.

Gate

On the branch as pushed (on main after #154): make rs-check green; make rs-check-drift green; make conformance-rs 191/191; make rs-consumer-check green (default and no-default features).

Adversarial review

Codex CLI (gpt-6-astra, reasoning xhigh) review runs against this diff; findings and dispositions follow in a comment.


Summary by cubic

Hardens the Rust SDK runtime and public contracts so failure handling is explicit, concurrent calls are safe, and diagnostics remain useful without exposing secrets. Pagination now reports incomplete walks instead of returning results that look complete.

Runtime behavior

  • ClientBuilder::operation_timeout covers credentials, retries, backoff, refreshes, response reads, downloads, and multi-request helpers; the existing timeout remains per-request.
  • Timed-out operations report the timeout to hooks, end dropped requests, and release resilience permits.
  • Concurrent 401s serialize refreshes and reuse newly refreshed credentials; refreshes continue after a caller times out, while failed refreshes remain retryable.
  • get_all, get_all_with_limit, follow_pagination, and each_page return Error::pagination_capped(max_pages) when more pages remain at the limit.
  • Complete walks, visitor-initiated stops, and empty pages with cursors continue to work normally.
  • JSON decode errors retain the HTTP status and request ID, and send, send_optional, and send_page include the operation name.
  • Debug output redacts credentials, request bodies, query values, and OAuth secrets; transport errors no longer include URLs.
  • TokenProvider implementations can now be shared through Arc.

Tests and tooling

  • Adds runtime, pagination, redaction, diagnostics, and compile-time Send/Sync contract tests.
  • Adds make rs-consumer-check to package the crate and build an external consumer with default and no default features.