Skip to main content

HandlerLimits

Struct HandlerLimits 

Source
#[non_exhaustive]
pub struct HandlerLimits {
Show 16 fields pub max_id_length: usize, pub max_metadata_size: usize, pub max_cancellation_tokens: usize, pub max_token_age: Duration, pub push_delivery_timeout: Duration, pub push_delivery_budget: Duration, pub executor_drain_timeout: Duration, pub max_artifacts_per_task: usize, pub max_context_locks: usize, pub max_push_configs_per_task: usize, pub max_parts_per_artifact: usize, pub max_total_push_configs: usize, pub subscribe_reattach_interval: Duration, pub subscribe_max_idle: Duration, pub subscribe_replay_limit: usize, pub subscribe_replay_catchup: Duration,
}
Expand description

Configurable limits for the request handler.

All fields have sensible defaults. Create with HandlerLimits::default() and override individual values as needed.

§Example

use a2a_protocol_server::handler::HandlerLimits;

let limits = HandlerLimits::default()
    .with_max_id_length(2048)
    .with_max_metadata_size(2 * 1024 * 1024);

#[non_exhaustive]: build it with Default and the with_* setters, which cover every field; a struct literal is not available outside this crate, so a field added later does not break callers.

Fields (Non-exhaustive)§

This struct is marked as non-exhaustive
Non-exhaustive structs could have additional fields added in future. Therefore, non-exhaustive structs cannot be constructed in external crates using the traditional Struct { .. } syntax; cannot be matched against without a wildcard ..; and struct update syntax will not work.
§max_id_length: usize

Maximum allowed length for task/context IDs. Default: 1024.

message.id is bounded too, but never below MIN_MESSAGE_ID_LENGTH — see effective_max_message_id_length for why it cannot simply share this number.

§max_metadata_size: usize

Maximum allowed serialized size for metadata fields in bytes. Default: 1 MiB.

§max_cancellation_tokens: usize

Maximum cancellation token map entries before cleanup sweep. Default: 10,000.

A sweep threshold, not a hard bound: the sweep only evicts cancelled or aged-out entries whose executor is gone — a token belonging to a live task is never removed, so with more than this many tasks genuinely in flight the map tracks the in-flight count instead.

§max_token_age: Duration

Maximum age for cancellation tokens. Default: 1 hour.

§push_delivery_timeout: Duration

Timeout for individual push webhook deliveries. Default: 5 seconds.

Bounds how long the handler waits for a single push notification delivery to complete, preventing one slow webhook from blocking all subsequent deliveries.

§This is a total, and the sender’s retries have to fit inside it

The bound covers the whole PushSender::send call, retries included — not one HTTP request. A sender whose own schedule is longer never finishes it, and the attempts it advertises simply do not happen.

The shipped defaults contradict each other. HttpPushSender::new() is three attempts at a 30-second request timeout with [1s, 2s] backoff after a 5-second DNS bound — 98 seconds — against this 5-second bound. Measured 2026-08-19 against a real socket: one of the three attempts reaches the webhook, and the bound fires at 5.001s. So max_attempts and backoff are, at the defaults, configuration that cannot take effect.

The two numbers pull in opposite directions and neither is obviously wrong. Raising this bound to fit the retries makes the 30-second per-event budget in deliver_push_bg reachable by a single config, which is the amplification ceiling that budget exists to hold. Shrinking the sender’s schedule to fit gives real webhooks less time than a slow one legitimately needs. Choosing between them is a deployment decision, so this documents the arithmetic rather than picking:

attempts_that_run == 1 + how many whole (request_timeout + backoff)
                         cycles fit in push_delivery_timeout

A sender that reports PushSender::max_delivery_duration gets the truncation counted rather than mistaken for a slow endpoint — see push_outcome::TIMEOUT_TRUNCATED.

§push_delivery_budget: Duration

Total time one event’s push deliveries may take, across every registered config. Default: 30 seconds.

This is the amplification ceiling: a task with max_push_configs_per_task webhooks that all time out would otherwise spend configs x push_delivery_timeout x attempts per event. Deliveries run one after another, so the configs an event reaches is min(configs, push_delivery_budget / push_delivery_timeout); the rest are counted as push_outcome::SKIPPED. On the blocking send path the same budget covers the whole batch of events a request produced, not each event, because that delivery is spawned once per request.

Until 0.12 this was a Duration::from_secs(30) literal in two places and the term deciding which webhooks were called was not a knob.

§executor_drain_timeout: Duration

How long the blocking send path waits, after the executor has finished, for the event queue to close. Default: 5 seconds.

Once the executor returns, everything it wrote is already buffered and is drained immediately; the only thing left to wait for is the queue closing, which EventQueueManager::destroy does from the cleanup guard’s Drop. An executor that returns without reaching a terminal or interrupted state, on a queue that never closes, used to hold the blocking SendMessage open forever. When this bound elapses the response is the task as collected so far, exactly what a closed queue would have produced, and Metrics::on_error is called with error_kind = "executor_drain_timeout".

§max_artifacts_per_task: usize

Maximum number of artifacts per task. Default: 1000.

Prevents unbounded memory growth and O(n²) serialization cost when executors emit many artifacts. Once the limit is reached, new artifact updates are rejected.

§max_context_locks: usize

Maximum number of per-context locks before cleanup. Default: 10,000.

Context locks serialize concurrent SendMessage requests for the same context_id. Stale entries (where no other reference is held) are pruned when this limit is reached. Like max_cancellation_tokens this is a prune threshold, not a hard bound — entries currently held by in-flight requests are never pruned.

§max_push_configs_per_task: usize

Maximum number of push notification configs per task. Default: 100.

Enforced by the handler on CreateTaskPushNotificationConfig so the cap applies uniformly across all store backends. Without it, the SQL stores (which do not self-enforce) let a client mint unbounded configs for a single task — a disk-exhaustion vector, and a delivery-amplification vector since every stream event fans out to all of a task’s configs. Updating an existing config (same id) does not count against the cap.

§max_parts_per_artifact: usize

Maximum number of parts a single artifact may accumulate. Default: 10,000.

max_artifacts_per_task bounds the artifact count, but a stream of TaskArtifactUpdateEvents with append: true grows one artifact’s parts without bound. Since executors routinely stream model output derived from attacker-influenced prompts, this bounds the cumulative per-artifact (and thus per-task) size. Appends that would exceed the cap are dropped.

§max_total_push_configs: usize

Global ceiling on the total number of push configs a store may hold (per-tenant for tenant-scoped stores). Default: 100,000.

Complements max_push_configs_per_task: the per-task cap alone lets a client mint configs for unboundedly many distinct task ids (100 each), growing a SQL-backed table without limit. Enforced whenever the store reports a count (see PushConfigStore::count); stores that do not report one are unaffected.

§subscribe_reattach_interval: Duration

How often a SubscribeToTask stream re-checks whether its task has finished, once the current turn’s event queue has closed. Default: 250ms.

A task’s queue lives only as long as one executor invocation, so an agent that parks a task in input_required closes the queue at every turn boundary. Spec §3.1.6 requires the stream to run until a terminal state, so it waits here for the next turn rather than ending. Only an idle stream pays this cost — a live queue delivers events immediately.

§subscribe_max_idle: Duration

How long a SubscribeToTask stream waits for a parked task to make progress before ending. Default: 5 minutes.

Without a bound, a task left in input_required forever would pin a connection forever. Ending the stream is safe: §3.5.2 makes reconnection an expected flow, and the client gets a fresh snapshot when it resubscribes.

§subscribe_replay_limit: usize

How many logged events a resuming SubscribeToTask replays at most. Default: 1,000.

A client reconnecting with Last-Event-ID is sent what it missed, read from the task’s event log. The bound matters because the offset is client-supplied: Last-Event-ID: 0 on a long-running task asks for the whole history, and without a cap that is an unbounded read and an unbounded burst of frames on one connection.

Truncation is not silent data loss. Every replayed frame carries its own id:, so a client that receives the cap’s worth reconnects at the last one and continues — the replay is resumable by the same mechanism that started it.

§subscribe_replay_catchup: Duration

How long a resuming SubscribeToTask waits for the task’s event log to catch up with what has already been broadcast. Default: 2 seconds.

Events reach the log through the background processor, so a position can have been broadcast to live subscribers before it has been appended. A resubscribe attaches its broadcast receiver first and reads the log second; without this wait, a position broadcast just before the receiver existed and appended just after the log was read appears in neither, and the subscriber loses it with no gap it could detect.

The wait is bounded because the processor can be slow for reasons that are not this subscriber’s problem — a registered webhook is delivered inline, under push_delivery_budget. On expiry the replay is served with what the log does hold and the shortfall is logged and counted under the event_log_catchup persistence-error label, rather than being passed off as a complete history.

Zero disables the wait.

Implementations§

Source§

impl HandlerLimits

Source

pub const fn effective_max_message_id_length(&self) -> usize

The bound message.id is actually held to: never below MIN_MESSAGE_ID_LENGTH.

§Why this is not just max_id_length

It was, for one release, and that was a defect. context_id and task_id are commonly short and often chosen by the deployment; message.id is minted by the client and is conventionally a UUID. A deployment tightening max_id_length to anything under 36 — which is a reasonable thing to do for the two ids it was documented to cover — then rejected every message from a conformant client, including every one this SDK’s own examples send.

Caught by examples/incident-response’s handler-limits check, which sets max_id_length to 32 and asserts that an id within the bound is accepted. It failed on a 36-character UUID, which is exactly the report an adopter would have filed.

The clamp rather than a separate knob, for the same reason effective_batch_size floors a zero batch: an operator tightening a bound should not have to know that one identifier has a protocol- imposed floor, and a configuration that cannot serve a conformant client is not one worth honouring exactly.

Spelled as a saturating offset from the floor rather than the self.max_id_length > MIN_MESSAGE_ID_LENGTH it reads as, for the reason mutants.toml records for InMemoryTaskStore::evict: > mutates to >=, and at exactly the floor both arms return the same number, so the weakened operator is an equivalent mutant that no test can kill. The arithmetic below has no such form — saturating_sub is 0 at or below the floor, which lands the sum on the floor, and is the excess above it otherwise. Reported by the incremental mutation gate on this pull request (shard 1 of run 35523981742); the fix is the spelling, not the logic.

Source

pub const fn with_subscribe_reattach_interval( self, interval: Duration, ) -> HandlerLimits

Sets how often an idle SubscribeToTask stream re-checks its task.

Source

pub const fn with_subscribe_max_idle(self, max_idle: Duration) -> HandlerLimits

Sets how long a SubscribeToTask stream waits on a parked task.

Source

pub const fn with_subscribe_replay_limit(self, limit: usize) -> HandlerLimits

Sets how many logged events a resuming SubscribeToTask replays.

Source

pub const fn with_subscribe_replay_catchup( self, catchup: Duration, ) -> HandlerLimits

Sets how long a resuming SubscribeToTask waits for the event log to catch up with what has already been broadcast. Zero disables the wait.

Source

pub const fn with_max_id_length(self, length: usize) -> HandlerLimits

Sets the maximum allowed length for task/context IDs.

Source

pub const fn with_max_metadata_size(self, size: usize) -> HandlerLimits

Sets the maximum serialized size for metadata fields in bytes.

Source

pub const fn with_max_cancellation_tokens(self, max: usize) -> HandlerLimits

Sets the maximum cancellation token map entries before cleanup.

Source

pub const fn with_max_token_age(self, age: Duration) -> HandlerLimits

Sets the maximum age for cancellation tokens.

Source

pub const fn with_push_delivery_timeout( self, timeout: Duration, ) -> HandlerLimits

Sets the timeout for individual push webhook deliveries.

Source

pub const fn with_push_delivery_budget(self, budget: Duration) -> HandlerLimits

Sets the total push-delivery budget per event (per request batch on the blocking path). See push_delivery_budget.

Source

pub const fn with_executor_drain_timeout( self, timeout: Duration, ) -> HandlerLimits

Sets how long the blocking send path waits for the event queue to close after the executor finished. See executor_drain_timeout.

Source

pub const fn with_max_artifacts_per_task(self, max: usize) -> HandlerLimits

Sets the maximum number of artifacts per task.

Source

pub const fn with_max_push_configs_per_task(self, max: usize) -> HandlerLimits

Sets the maximum number of push notification configs per task.

Source

pub const fn with_max_total_push_configs(self, max: usize) -> HandlerLimits

Sets the global (per-tenant for tenant stores) ceiling on total push notification configs. Enforced only when the store reports a count.

Source

pub const fn with_max_parts_per_artifact(self, max: usize) -> HandlerLimits

Sets the maximum number of parts a single artifact may accumulate.

Source

pub const fn with_max_context_locks(self, max: usize) -> HandlerLimits

Sets the maximum number of per-context locks before cleanup.

Trait Implementations§

Source§

impl Clone for HandlerLimits

Source§

fn clone(&self) -> HandlerLimits

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for HandlerLimits

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>

Formats the value using the given formatter. Read more
Source§

impl Default for HandlerLimits

Source§

fn default() -> HandlerLimits

Returns the “default value” for a type. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
where ST: ?Sized, DT: ?Sized,

Source§

impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
where ST: ?Sized, DT: ?Sized,

Source§

impl<T> CloneToUninit for T
where T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> FromRef<T> for T
where T: Clone,

Source§

fn from_ref(input: &T) -> T

Converts to this type from a reference to the input type.
Source§

impl<T> FutureExt for T

Source§

fn with_context(self, otel_cx: Context) -> WithContext<Self> ⓘ

Attaches the provided Context to this type, returning a WithContext wrapper. Read more
Source§

fn with_current_context(self) -> WithContext<Self> ⓘ

Attaches the current Context to this type, returning a WithContext wrapper. Read more
Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self> ⓘ

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self> ⓘ

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoEither for T

Source§

fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ

Converts self into a Left variant of Either<Self, Self> if into_left is true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
where F: FnOnce(&Self) -> bool,

Converts self into a Left variant of Either<Self, Self> if into_left(&self) returns true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

impl<T> IntoRequest<T> for T

Source§

fn into_request(self) -> Request<T>

Wrap the input message T in a tonic::Request
Source§

impl<L> LayerExt<L> for L

Source§

fn named_layer<S>(&self, service: S) -> Layered<<L as Layer<S>>::Service, S>
where L: Layer<S>,

Applies the layer to a service and wraps it in Layered.
Source§

impl<T> Read<Exclusive, BecauseExclusive> for T
where T: ?Sized,

Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = !

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, !>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<V, T> VZip<V> for T
where V: MultiLane<T>,

Source§

fn vzip(self) -> V

Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self> ⓘ
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self> ⓘ

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more