Production Hardening
A checklist and guide for deploying a2a-rust agents in production.
Security
HTTPS
Always use HTTPS in production. The client (and the server's push sender)
ship TLS out of the box via the default tls-rustls feature — https://
agents and webhooks work with no extra setup. The server does not
terminate inbound TLS itself; put it behind a reverse proxy (nginx, Caddy,
cloud load balancer):
Client ──HTTPS──→ [nginx/Caddy] ──HTTP──→ [a2a-rust agent]
CORS
Configure CORS for browser-based clients:
#![allow(unused)] fn main() { use a2a_protocol_sdk::server::CorsConfig; // The dispatchers include CORS handling. // Configure allowed origins for production. }
Push Notification Security
The built-in HttpPushSender includes:
- SSRF protection — Rejects private/loopback IPs at config creation and delivery time (defense-in-depth), including IPv4-in-IPv6 smuggling, and pins the validated IP against DNS rebinding
- Header injection prevention — Validates credentials for
\r/\ncharacters - Per-request timeout — Each push delivery HTTP request is capped at 30 seconds
Transport note: with the
tls-rustlsfeature (enabled by default when usinga2a-protocol-sdk) the bundledHttpPushSenderdelivers to bothhttp://andhttps://webhooks. Without the feature it is plaintext-only and rejects anhttps://webhook with a clear error; supply a TLS-capablePushSenderimplementation viawith_push_senderin that configuration —PushSenderis a public, pluggable trait and the SSRF-validation helpers are reusable.
Path Traversal Protection
The REST dispatcher automatically rejects:
..in path segments- Percent-encoded
%2E%2Eand%2e%2e - Paths that escape the expected route hierarchy
Body Size Limits
| Limit | Value | Transport |
|---|---|---|
| Request body | 4 MiB | JSON-RPC and REST |
| Query string | 4 KiB | REST |
| Event size | 16 MiB (configurable) | All SSE transports |
Reliability
Executor Timeout
Prevent hung tasks from consuming resources forever. The default ceiling is
one hour (DEFAULT_EXECUTOR_TIMEOUT): an executor that never returns would
otherwise pin its task, queue and cancellation token for the life of the
process. Set one matched to your workload, or call without_executor_timeout()
if your executors are genuinely unbounded:
#![allow(unused)] fn main() { use a2a_protocol_sdk::prelude::*; struct MyAgent; agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) }); fn f(executor: MyAgent) -> ServerResult<RequestHandler> { use std::time::Duration; RequestHandlerBuilder::new(executor) .with_executor_timeout(Duration::from_secs(300)) .build() } }
Concurrent Stream Limits
Concurrent streaming requests are capped at 1024 by default (each stream
allocates channels and spawns background tasks). Tune the ceiling to your
deployment; pass usize::MAX to effectively disable it:
#![allow(unused)] fn main() { use a2a_protocol_sdk::prelude::*; struct MyAgent; agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) }); fn f(executor: MyAgent) -> ServerResult<RequestHandler> { RequestHandlerBuilder::new(executor) .with_max_concurrent_streams(1000) .build() } }
Task Store Limits
Prevent unbounded memory growth:
#![allow(unused)] fn main() { use a2a_protocol_sdk::prelude::*; struct MyAgent; agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) }); fn f(executor: MyAgent) -> ServerResult<RequestHandler> { use std::time::Duration; use a2a_protocol_sdk::server::TaskStoreConfig; RequestHandlerBuilder::new(executor) .with_task_store_config( TaskStoreConfig::default() .with_max_capacity(Some(100_000)) .with_task_ttl(Some(Duration::from_secs(3600))) .with_eviction_interval(64) .with_max_page_size(1000), ) .build() } }
Use TaskStore::count() for monitoring capacity utilization.
Graceful Shutdown
A graceful shutdown has to end in-flight work before it waits for sockets.
An open SSE stream is a connection that does not close until its task ends,
so a drain that runs first just waits out its timeout on tasks nobody has
cancelled — and whatever an executor delegated to other agents is still
running when the process exits. Server::serve_with_shutdown does it in the
order that works:
- stop accepting;
- let in-flight tasks finish on their own for up to
completion_grace(default 5 s), then cancel the rest and wait up totask_grace(default 10 s) for the executors to act on it — cancel what they delegated, return — after which the executor'scancelhook writes the terminalCanceledfor any that did not write one, so every open stream ends with a terminal event; - drain connections, for up to
drain_timeout(default 15 s).
#![allow(unused)] fn main() { use std::sync::Arc; use std::time::Duration; use a2a_protocol_server::dispatch::JsonRpcDispatcher; use a2a_protocol_server::serve::{ServeConfig, Server}; use a2a_protocol_server::RequestHandler; async fn run(handler: Arc<RequestHandler>) -> std::io::Result<()> { let server = Server::bind("0.0.0.0:3000").await?.with_config( ServeConfig::new() .with_completion_grace(Duration::from_secs(5)) .with_task_grace(Duration::from_secs(10)) .with_drain_timeout(Duration::from_secs(15)), ); let report = server .serve_with_shutdown(JsonRpcDispatcher::new(Arc::clone(&handler)), async { tokio::signal::ctrl_c().await.ok(); }) .await; // Last, the executor's cleanup hook. let handler_report = handler.shutdown().await; if let Some(tasks) = report.tasks.filter(|t| !t.finished) { eprintln!("{} task(s) ignored cancellation", tasks.still_running); } if !report.drained || !handler_report.is_graceful() { eprintln!("unclean shutdown: {report:?} {handler_report:?}"); } Ok(()) } }
Your executor has to take part: execute must watch
ctx.cancellation_token and, when it fires, cancel whatever it started
elsewhere and return. One that never looks at its token cannot be stopped
early; ServeReport::tasks counts it in still_running.
With Axum (or anything else that owns the sockets), call
handler.finish_in_flight(completion, grace) at the end of the future you pass to
with_graceful_shutdown, so it runs before Axum starts draining —
examples/deploy-agent does exactly that — and handler.shutdown() after
serve returns. The gRPC and WebSocket dispatchers have their own
serve_with_shutdown(listener, signal), which runs the same three steps and
returns the same ServeReport; set the three durations with
with_completion_grace, with_task_grace and with_drain_timeout on the
dispatcher. Two differences follow from the transports. gRPC connections are
sent GOAWAY when accepting stops, so they take no new calls during the
completion window, where HTTP and WebSocket connections still do. And a
WebSocket carries many requests on one connection, so once the tasks have
ended each connection finishes the requests it has already read and is closed
with a Close frame. finish_in_flight first lets tasks finish on
their own for up to completion — so a short call in flight during a rolling
deploy is answered rather than Canceled — and then does what
cancel_in_flight(grace) does alone: cancels the rest and waits for them.
Implement on_shutdown in your executor for cleanup:
#![allow(unused)] fn main() { use std::future::Future; use std::pin::Pin; use a2a_protocol_sdk::prelude::*; struct MyAgent { db_pool: sqlx::PgPool, cancel_token: a2a_protocol_sdk::server::CancellationToken } impl AgentExecutor for MyAgent { fn execute<'a>(&'a self, _: &'a RequestContext, _: &'a dyn EventQueueWriter) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> { Box::pin(async { Ok(()) }) } fn on_shutdown<'a>(&'a self) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> { Box::pin(async move { self.db_pool.close().await; self.cancel_token.cancel(); }) } } }
Rate Limiting
Protect public-facing agents from abuse:
#![allow(unused)] fn main() { use a2a_protocol_sdk::prelude::*; struct MyAgent; agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) }); fn f(executor: MyAgent) -> ServerResult<RequestHandler> { use a2a_protocol_sdk::server::{RateLimitInterceptor, RateLimitConfig}; RequestHandlerBuilder::new(executor) .with_interceptor( RateLimitInterceptor::new( RateLimitConfig::default() .with_requests_per_window(100) .with_window_secs(60) // Set to the number of trusted reverse proxies so the client // IP is taken from X-Forwarded-For; 0 (default) ignores it. .with_trusted_proxy_hops(1), ) .expect("valid rate limit config"), ) .build() } }
For a limit shared across replicas, use with_shared_counter with
PostgresRateLimitCounter or your own RateLimitCounter — see
Running More Than One Replica.
For sliding windows, use a reverse proxy or implement a custom
ServerInterceptor.
Client Retry & Reuse
When calling remote agents, build clients once and reuse them. Connection reuse is critical for performance — creating a new client per request bypasses HTTP keep-alive and connection pooling, adding ~300-500us of overhead per call:
#![allow(unused)] fn main() { use a2a_protocol_sdk::prelude::*; async fn f(params: MessageSendParams) { // Build once at startup let client = ClientBuilder::new("http://agent.example.com") .with_retry_policy(RetryPolicy::default()) .build() .unwrap(); // Reuse across all requests — client holds a connection pool let result = client.send_message(params).await; } }
Observability
Structured Logging
The client, the server and the SDK log through tracing by default (the
tracing feature). Install a subscriber to see the output:
[dependencies]
a2a-protocol-server = "0.14"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
#![allow(unused)] fn main() { use tracing_subscriber::EnvFilter; tracing_subscriber::fmt() .with_env_filter( EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new("info")) ) // For log aggregation, enable tracing-subscriber's `json` feature and // add `.json()` here. .init(); }
Set RUST_LOG=debug for verbose output, RUST_LOG=a2a_protocol_server=debug for server-specific logs.
Health Checks
The REST dispatcher and A2aRouter answer GET /health and GET /ready
themselves. The JSON-RPC dispatcher does not; route the path yourself in front
of it, answering with the dispatcher's own body type:
#![allow(unused)] fn main() { use std::convert::Infallible; use std::sync::Arc; use a2a_protocol_sdk::server::JsonRpcDispatcher; use http_body_util::{BodyExt, Full, combinators::BoxBody}; use hyper::body::{Bytes, Incoming}; async fn route( dispatcher: Arc<JsonRpcDispatcher>, req: hyper::Request<Incoming>, ) -> hyper::Response<BoxBody<Bytes, Infallible>> { if req.uri().path() == "/health" { hyper::Response::new(Full::new(Bytes::from("ok")).boxed()) } else { dispatcher.dispatch(req).await } } }
Performance
Connection Handling
Both dispatchers use hyper's HTTP/1.1 and HTTP/2 support via hyper_util::server::conn::auto::Builder. This automatically negotiates the best protocol version.
TCP_NODELAY is enabled on all server and client TCP sockets. This disables Nagle's algorithm, eliminating the ~40ms delayed-ACK latency that would otherwise penalize small SSE frames and JSON-RPC responses on loopback and low-latency networks.
Task Store Performance
The InMemoryTaskStore uses a pre-allocated HashMap with secondary indexes for efficient queries:
- O(1) amortized save/get/delete — constant-time operations regardless of store size
- No resize-induced latency spikes — pre-allocation to the configured
max_capacityeliminates the periodic full-rehash events that cause unpredictable 5-7× latency cliffs when the table outgrows its capacity - O(log n + page_size) list queries — a
BTreeMap<(i64, u64), TaskId>index keyed by (status timestamp, write sequence) gives the spec's status-timestamp-descending order and O(log n) cursor positioning, and aHashMap<String, BTreeMap<(i64, u64), TaskId>>context index enables O(log m + page_size) filtered queries where m = matching tasks. This replaces the previous O(n log n) per-call sort that caused 20-70× regressions at 10K+ tasks.
No Web Framework Overhead
a2a-rust works directly with hyper — no middleware framework overhead. Cross-cutting concerns (rate limiting, request logging, auth) are handled via the interceptor chain rather than a framework.
Event Queue Sizing
Tune the event queue for your workload:
#![allow(unused)] fn main() { use a2a_protocol_sdk::prelude::*; struct MyAgent; agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) }); fn f(executor: MyAgent) -> ServerResult<RequestHandler> { let builder = RequestHandlerBuilder::new(executor); // High-throughput: larger queues for tasks producing >250 events/task let builder = builder.with_event_queue_capacity(512); // Memory-constrained: smaller queues let builder = builder.with_event_queue_capacity(64); builder.build() } }
Benchmark data: Per-event cost inflects at the broadcast channel capacity boundary. With the default capacity of 256 (increased from 64), tasks producing
250 events see increased per-event overhead due to broadcast buffer pressure (~4µs/event below capacity, ~193µs/event above capacity). Set capacity to at least your expected peak event count per task. The
serde_helpers::SerBuffermodule can further reduce per-event serialization overhead via thread-local buffer reuse.
Deployment Checklist
- HTTPS termination configured
- CORS origins restricted to known clients
- Executor timeout set
- Max concurrent streams limited
- Task store TTL and capacity configured
- Rate limiting enabled for public-facing agents
- A2A clients built once and reused (not per-request)
- Structured logging enabled
- Health check endpoint available
- Push notification URLs restricted to HTTPS
- Body/query size limits verified
- Graceful shutdown implemented
Next Steps
- GitHub Pages & CI/CD — Continuous deployment
- Configuration Reference — All configuration options