A typed async HTTP client for services built on the
acton-service framework.
acton-service-client is the consumer-side counterpart to acton-service.
Every service built on that framework shares a fixed set of wire conventions —
error-body shape, versioned route layout, health/readiness endpoints,
request-tracking headers, rate-limit signalling, and bearer auth. This crate
encodes those conventions once, as typed Rust, so downstream clients don't have
to reinvent them.
The mirrored types (ErrorResponse, ApiVersion, HealthResponse,
ReadinessResponse, DependencyStatus) are verified against the
acton-service 0.27 source and are Serialize + Deserialize, so they
round-trip against the genuine framework structs.
| Convention | acton-service source | Types here |
|---|---|---|
Error body {error, code?, status} |
error.rs::ErrorResponse |
ErrorResponse, ApiError |
| Error codes (SCREAMING_SNAKE) | error.rs (NOT_FOUND, RATE_LIMIT_EXCEEDED, ACCOUNT_LOCKED, …) |
ApiError::code |
Versioned routes {base_path}/{version} |
versioning.rs::ApiVersion |
ApiVersion (V1–V5) |
GET /health (unversioned) |
health.rs::HealthResponse |
HealthResponse |
GET /ready (unversioned) |
health.rs::ReadinessResponse |
ReadinessResponse, DependencyStatus |
| Request-tracking headers | middleware/request_tracking.rs |
RequestContext (5-header set) |
Rate limiting (RateLimit-* on 429) |
rate-limit middleware | RateLimitInfo |
Retry-After on 429 / 423 / 503 |
error.rs (423 Retry-After) |
ApiError::retry_after |
Bearer auth (Authorization: Bearer …) |
auth middleware | ServiceClientBuilder::bearer_token |
use acton_service_client::{ApiVersion, RetryPolicy, ServiceClient};
use std::time::Duration;
#[derive(serde::Serialize, serde::Deserialize)]
struct User { id: u64, name: String }
# async fn run() -> Result<(), acton_service_client::ClientError> {
let client = ServiceClient::builder("https://api.example.com")
.api_version(ApiVersion::V1) // default V1
.base_path("/api") // default "/api"
.bearer_token("token") // optional
.timeout(Duration::from_secs(30)) // sane default
.retry(RetryPolicy::default()) // optional; off by default
.build()?;
let user: User = client.get("users/42").await?; // GET /api/v1/users/42
let created: User = client.post("users", &user).await?; // 200 or 201
client.delete("users/42").await?; // 204 -> ()
let health = client.health().await?; // unversioned /health
let ready = client.ready().await?; // unversioned /ready
# let _ = (created, health, ready);
# Ok(())
# }client.request(method, path) returns a RequestBuilder for query params,
extra headers, a per-request propagation RequestContext, a retriable override,
and additional accepted statuses:
# use acton_service_client::{RequestContext, ServiceClient};
# use reqwest::Method;
# async fn run(client: ServiceClient) -> Result<(), acton_service_client::ClientError> {
# #[derive(serde::Deserialize)] struct Page;
let page: Page = client
.request(Method::GET, "users")
.query("page", "2")
.context(RequestContext::new().with_correlation_id("corr-123"))
.send_json()
.await?;
# let _ = page; Ok(())
# }For anything the builder does not surface — a client certificate for mutual
TLS, a custom root store, a proxy, or a shared connection pool — build a
reqwest::Client and hand it to with_http_client. This is how you pair the
crate with an acton-service listener that verifies client certificates: give
reqwest the client identity, then pass the client in. The reqwest crate is
re-exported at the crate root so the client you build matches the type the
builder expects.
# use acton_service_client::ServiceClient;
# fn make_tls_client() -> reqwest::Client { unimplemented!() }
# fn run() -> Result<(), acton_service_client::ClientError> {
let mtls: reqwest::Client = make_tls_client(); // use_rustls_tls() + Identity::from_pem(..)
let client = ServiceClient::builder("https://api.example.com")
.bearer_token("token") // still sent, per-request
.with_http_client(mtls)
.build()?;
# let _ = client; Ok(())
# }bearer_token and default_header are sent per-request, so they keep working
with a supplied client. Only timeout is ignored on this path — set it on the
client you pass in.
Every fallible call returns ClientError:
Api(Box<ApiError>)— a non-success HTTP status. Carries the deserializedErrorResponse, theStatusCode, any parsedRateLimitInfo, and anyRetry-After. A body that is not validErrorResponseJSON is preserved as the error message (raw text) rather than lost — the status code is never dropped and the client never panics.Transport(reqwest::Error)— connection/TLS/timeout failures.Decode { status, snippet, source }— a success body that failed to deserialize; keeps a truncated snippet for diagnostics.Config(String)— builder-time validation (e.g. bad base URL).DeadlineExceeded { attempts, elapsed }: the call's deadline expired afterattemptsrequests were started, and any of them may have been applied. Onlyattempts == 0guarantees nothing was sent (today the crate returns it only in that case). Not retriable.InvalidEndpoints(EndpointSetError): the endpoint set was refused at build time (duplicate origin, mixed schemes, or a failover endpoint that is not a bare origin).EndpointsExhausted(Box<FailoverTrace>): a call over an endpoint set ran out of budget after failing over. The trace lists every attempt (endpoint, outcome, whether it rotated) and the final attempt's own error. Not retriable.
ClientError is #[non_exhaustive]: match it with a wildcard arm.
ApiError::is_retriable() is true for 429, 502, 503, 504, and for 423
only when a Retry-After was supplied.
Retries are off by default. Configure a RetryPolicy to enable exponential
backoff (with a cap). Retries apply only to idempotent methods
(GET/HEAD/DELETE/PUT) plus any request explicitly marked
.retriable(true). A server Retry-After is honored when present; otherwise
the pause comes from RetryPolicy::pause, a pure, unit-tested function of the
attempt, a random draw, and the time remaining.
# use acton_service_client::{Jitter, Method, RetryPolicy, ServiceClient, StatusCode};
# use std::time::Duration;
# async fn run() -> Result<(), acton_service_client::ClientError> {
# #[derive(serde::Deserialize)] struct Answer;
let client = ServiceClient::builder("https://api.example.com")
.retry(
RetryPolicy::default()
.max_attempts(10)
.jitter(Jitter::Full) // spread pauses over [base, ceiling]
.deadline(Duration::from_secs(3)), // total budget per call
)
.build()?;
let answer: Answer = client
.request(Method::POST, "authorize")
.retriable(true) // POST: explicit opt-in
.retry_on_status(StatusCode::MISDIRECTED_REQUEST)
.timeout(Duration::from_millis(500)) // per attempt
.send_json()
.await?;
# let _ = answer; Ok(())
# }- Deadline.
RetryPolicy::deadlinebounds a whole call from its first send. Each attempt's timeout ismin(timeout, remaining), and a pause (from backoff orRetry-After) that would reach the deadline is not taken: the last error or response is returned.RequestBuilder::deadline_at(Instant)sets an absolute deadline instead, so several sends of one operation share one budget. If the budget is already spent before anything is sent, the call fails withClientError::DeadlineExceededand the server never sees it. Without a deadline,Retry-Afteris honoured uncapped, as in 0.1: set a deadline to bound it. - Jitter.
Jitter::Fulldraws each pause uniformly from[base_delay, ceiling]. The floor atbase_delayis deliberate: unlike textbook full jitter, no pause is ever near zero, so an always-failing upstream is never hammered in a tight loop. - Per-attempt timeout. The request's
.timeout()wins, then the client'sServiceClientBuilder::attempt_timeout, then the builder'stimeout. Under a deadline, whichever applies is clamped to the time remaining. With a client supplied viawith_http_client, a deadline replaces that client's own timeout with the remaining budget on every attempt. Setattempt_timeoutto keep a tighter bound. - Extra statuses.
RequestBuilder::retry_on_statusextends the retriable set per request. It is checked beforeaccept_status, so an accepted status listed for retry is retried first and still returned raw once retries run out. It only applies where retries do (idempotent method or.retriable(true)).
Defaults (no deadline, no jitter, no extra statuses) behave exactly as 0.1.
Give a client an ordered set of endpoints serving the same API, and one deadline covers every attempt on every endpoint:
# use acton_service_client::{
# ClientError, EndpointOrigin, Method, RetryObserver, RetryPolicy, RetryReason, ServiceClient,
# StatusCode,
# };
# use std::time::Duration;
# async fn run() -> Result<(), ClientError> {
struct Log;
impl RetryObserver for Log {
fn on_rotation(&self, left: &EndpointOrigin, reason: RetryReason) {
eprintln!("left {left}: {}", reason.label()); // or count it as a metric
}
}
let client = ServiceClient::builder("https://replica-a.example.com")
.failover_endpoints(["https://replica-b.example.com", "https://replica-c.example.com"])
.attempt_timeout(Duration::from_secs(5))
.retry(RetryPolicy::default().max_attempts(u32::MAX).deadline(Duration::from_secs(15)))
.retry_observer(Log)
.build()?;
match client
.request(Method::POST, "authorize")
.retriable(true)
.retry_on_status(StatusCode::MISDIRECTED_REQUEST)
.send()
.await
{
Err(ClientError::EndpointsExhausted(trace)) if trace.proves_not_processed() => {
// Every endpoint refused it (421 or connect): nothing was applied.
}
other => { let _ = other?; }
}
# Ok(())
# }- Validated at build. Failover endpoints are bare origins
(
scheme://host[:port]); each request reuses the base URL's path. Duplicate origins (after normalization) and mixed schemes are a typedClientError::InvalidEndpoints, never a panic.Endpoint::with_http_clientgives one endpoint its own client (for example its own TLS configuration). - Retriable requests only. Failover follows the retry rules: idempotent
methods, or
.retriable(true). A request that is not retried goes to one endpoint only, exactly as without a set. - Rotation. A
retry_on_statusstatus, a connect failure, or an attempt timeout moves the next attempt to the next endpoint at once. After a full cycle, the client pauses with the policy's backoff (or the smallestRetry-After, when every endpoint in the cycle sent one). Statuses retriable by default but not listed (429,502,503,504) retry the same endpoint, and every other answer is returned, as before. A transport failure after the request was written (a reset mid-body) is not a connect failure: it is returned asClientError::Transportand never retried, exactly as in 0.2.0, since the endpoint may have processed it and a patch release must not change what a single-endpoint caller sees. Treat it as ambiguous and re-send with the same idempotency identity. - Sticky. The next call starts at the endpoint that last gave a definitive
answer (any status that is not a rotation status, a
4xxincluded). - Stays in the set. A built client follows redirects only within the set;
a supplied client that follows one out of it fails with
ClientError::Config. - Diagnosable. Running out of budget after a rotation returns
EndpointsExhaustedwith the trace. Every responsesendreturns carries its own trace too, read withAttemptTrace::of(&response): the attempts that asked for another try, ending with the response itself when the budget ran out on an accepted status.RetryReason::proves_not_processedis true only for a connect failure and421. - Metrics. A
RetryObserverhears of every re-send:on_rotationwhen the call moves to the next endpoint,on_retrywhen it re-sends to the same one (so a single-endpoint client answering421is visible too). Both default to doing nothing, and the crate has no metrics dependency. In anacton-serviceapplication, count them onacton_service::observability::get_meter()asacton_service_client.endpoint.rotations{reason}andacton_service_client.endpoint.retries{reason}, withreason.label(). The observer runs synchronously on the caller's task. - Per request.
RequestBuilder::retry_observerinstalls an observer on one request, called in addition to the client's (the client's first). A request's observer hears exactly that request's re-sends, every attempt but the last, in order, with its outcome; the request's result is the last. That is the complete per-attempt record of one call, on the success path and under concurrency. The client's observer hears the union of every request's re-sends: in order within each request, with no order promised across concurrent requests, which is right for counters and wrong for deciding what happened to one call. To derive a per-call answer (such as "not submitted anywhere"), install a fresh collector on that request. - Where the record lives. An
Okresponse carries its attempts inAttemptTrace::of(&response).EndpointsExhaustedcarries every attempt in itsFailoverTrace.DeadlineExceededand every other error (a single endpoint's, or a call that never rotated, returned exactly as in 0.2.0) carry no trace: the request's own observer is the record there, and it covers the other paths too. - Parity.
spec/fixtures/endpoint-failover-v1.jsonpins the rules for every port; the Rust crate runs it on a virtual clock and over real HTTP.
A client with one endpoint behaves exactly as 0.2.0.
| Area | What you get |
|---|---|
| Transport | reqwest with rustls TLS (no OpenSSL), JSON, and query support |
| Verbs | get, post, put, patch, delete, plus request / request_unversioned |
| Versioning | ApiVersion V1–V5, path segment + parsing that agrees with acton-service |
| Health | health() and ready() against the unversioned endpoints |
| Tracking | RequestContext for the five propagation headers; auto x-request-id (UUID v4) |
| Rate limits | RateLimitInfo surfaced on ApiError |
| Auth | Bearer tokens (JWT or PASETO, opaque to the client) |
| Retries | Opt-in RetryPolicy: pure backoff math, floored jitter, total deadline, per-request extra statuses and timeouts |
| Failover | Ordered endpoint set under one deadline: validated at build, sticky, in-set redirects only, typed trace on exhaustion, retry observer |
cargo fmt --all
cargo clippy --all-targets -- -D warnings # zero warnings
cargo nextest run # unit + integration
cargo test --doc # doctests
cargo doc --no-deps # warning-freeIntegration tests exercise a real HTTP round-trip against an ephemeral
plain-axum server that reproduces the acton-service wire shapes
byte-for-byte (see tests/integration.rs for the rationale behind the fake vs.
a genuine acton-service router).
MIT