Cookbook / Production
Calling other services safely
An outbound HTTP client with SSRF protection, timeouts, retries and a circuit breaker.
Calling a vendor API, or fetching a URL a user gave you, has failure modes a plain HTTP client ignores. axeon-client wraps reqwest with the protections you would otherwise write yourself: it refuses private and internal addresses (server-side request forgery), applies timeouts, retries only when it is safe, and stops hammering a service that is down.
cargo add axeon-client
use axeon::metrics::Metrics;
use axeon::{Json, Server, ServerError};
use axeon_client::{Client, Egress};
use axeon::json;
use serde::Deserialize;
use std::time::Duration;
// State is looked up by type, so give each client its own type.
struct Crawler(Client);
#[allow(dead_code)]
struct Vendor(Client);
#[derive(Deserialize)]
struct Fetch {
url: String,
}
fn main() {
tracing_subscriber::fmt().with_target(false).init(); // failures are logged, not sent to clients
let metrics = Metrics::new();
// URLs from users (a link preview, a crawler): any public host, nothing internal.
let crawler = Client::builder("crawler")
.egress(Egress::public())
.timeout(Duration::from_secs(5))
.metrics(&metrics)
.build()
.expect("client");
// A vendor: only its hosts, with its credentials.
let vendor = Client::builder("vendor")
.egress(Egress::hosts(["api.vendor.example"]))
.timeout(Duration::from_secs(5))
.metrics(&metrics)
.build()
.expect("client");
let mut app = Server::new();
app.middleware(metrics.clone()).mount("/", metrics.router());
app.state(Crawler(crawler));
app.state(Vendor(vendor));
// GET /fetch?url=https://example.com
app.get("/fetch", |req| async move {
let target: Fetch = req.query_as()?;
let crawler = &req.state::<Crawler>().unwrap().0;
// A blocked address or a failure becomes 502, 503 or 504 with `?`.
let response = crawler.get(&target.url).context(&req).send().await?;
let status = response.status().as_u16();
let body = response.text().await.map_err(axeon_client::Error::from)?;
Ok::<_, ServerError>(Json(json!({ "status": status, "bytes": body.len() })))
});
app.listen("127.0.0.1:3000").expect("server failed to start");
}
curl "http://127.0.0.1:3000/fetch?url=https://example.com"
curl -i "http://127.0.0.1:3000/fetch?url=http://169.254.169.254/latest/meta-data/" # refused
curl -i "http://127.0.0.1:3000/fetch?url=http://127.0.0.1:3000/metrics" # refused
What you get#
- Egress control. By default only public addresses are reachable. Private networks, loopback, link-local and cloud metadata addresses are refused, and the check runs on the addresses a name *resolves to*, on IP-literal URLs and on every redirect, so
evil.examplepointing at10.0.0.1is blocked too. - Timeouts. 10 seconds per attempt and 3 to connect, by default.
- Retries, only when safe. Up to 3 attempts with backoff and jitter. Connection failures retry for any request, since nothing was sent. Timeouts and 429/502/503/504 retry only for idempotent requests (
GET,PUT,DELETE, or anything with anIdempotency-Key).Retry-Afteris honoured. - Circuit breaking. After 5 failures in a row a service fails fast for 30 seconds, then one probe decides.
- Request IDs.
.context(&req)passesX-Request-Idandtraceparenton, so one request can be followed across services. - Metrics per client name, so crawling arbitrary hosts cannot create unbounded series.
Notes#
- Register each client under its own type, as above: shared state is looked up by type, so two bare
Clients would replace each other. - Environment proxies are ignored on purpose: a proxy would resolve names itself, around the egress checks.
allow_private()exists for your own internal services. Never combine it with user-supplied URLs.