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/\n characters
  • Per-request timeout — Each push delivery HTTP request is capped at 30 seconds

Transport note: with the tls-rustls feature (enabled by default when using a2a-protocol-sdk) the bundled HttpPushSender delivers to both http:// and https:// webhooks. Without the feature it is plaintext-only and rejects an https:// webhook with a clear error; supply a TLS-capable PushSender implementation via with_push_sender in that configuration — PushSender is 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%2E and %2e%2e
  • Paths that escape the expected route hierarchy

Body Size Limits

LimitValueTransport
Request body4 MiBJSON-RPC and REST
Query string4 KiBREST
Event size16 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:

  1. stop accepting;
  2. let in-flight tasks finish on their own for up to completion_grace (default 5 s), then cancel the rest and wait up to task_grace (default 10 s) for the executors to act on it — cancel what they delegated, return — after which the executor's cancel hook writes the terminal Canceled for any that did not write one, so every open stream ends with a terminal event;
  3. 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_capacity eliminates 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 a HashMap<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::SerBuffer module 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