Concurrency & Limits
Per-agent concurrency caps, the 10MB response-size limit, and timeout capping that protect the A2A gateway from overload and runaway agents.
The A2A gateway protects itself and your agents with three hard limits: a per-agent
concurrency cap, a response-size cap, and timeout capping. Each is enforced by
the agent's virtual subscriber, returns a predictable error when crossed, and is tunable
through A2aConfig. This guide explains the
behavior of each limit and shows how to observe it from a client.
Overview
Every registered agent runs behind a virtual subscriber — the bridge that turns an
inbound JSON-RPC request into an outbound HTTP POST to the agent's URL. That subscriber is
where the limits live, so they apply per agent, not per client. Multiple callers
hitting the same agent_id share the same budget.
| Limit | Config field | Default | What happens when exceeded |
|---|---|---|---|
| Concurrent in-flight requests | AgentMaxConcurrency | 100 | Overflow request rejected immediately — Executed: false, JSON-RPC -32603 |
| Agent response body size | AgentMaxResponseBytes | 10485760 (10 MB) | Response dropped — Executed: false, JSON-RPC -32603 |
| Per-request timeout | DefaultTimeoutSeconds / MaxTimeoutSeconds | 300 / 3600 | Capped silently, or -32001 (agent timeout) on expiry |
All three surface as transport errors (Executed: false) — the request never reached
your agent's business logic, so a retry is generally safe. See
Error handling for the transport-vs-application
distinction.
Per-agent concurrency limit
Each agent's virtual subscriber holds a buffered-channel semaphore sized to
AgentMaxConcurrency (default 100). The count covers both synchronous
(message/send) and streaming (message/stream) requests in flight at the same time.
| Request # | Behavior |
|---|---|
| 1–100 | A handler is spawned and the request is proxied to the agent |
| 101+ | Immediately rejected — no goroutine spawned, the agent is never called |
When the semaphore is full, the overflow request comes back as a JSON-RPC -32603 error:
{
"jsonrpc": "2.0",
"id": 101,
"error": {
"code": -32603,
"message": "internal error: concurrency limit exceeded"
}
}At the transport layer this is recorded as Executed: false with the reason
server busy: concurrency limit reached. Because the request was never processed,
retrying after a short backoff is safe.
The limit is per agent, not per client. To raise headroom for a busy agent, increase
AgentMaxConcurrency in configuration — or run more
agent instances behind distinct agent_ids and route across them.
Observe the limit
Fire more than AgentMaxConcurrency requests at one agent simultaneously and at least one
comes back with -32603. The snippets below send 101 concurrent message/send
requests and count how many were rejected.
# Fire 101 requests in parallel; at least one returns -32603 "concurrency limit exceeded".
for i in $(seq 1 101); do
curl -s -X POST http://localhost:9090/a2a/concurrency-agent-01 \
-H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"id\":$i,\"method\":\"message/send\",\"params\":{\"message\":{\"parts\":[{\"text\":\"Request #$i\"}]}}}" &
done | grep -c -- -32603
wait
# => prints the number of requests rejected by the concurrency limit (>= 1)using System.Text;
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
const string AgentId = "concurrency-agent-01";
const int NumRequests = 101;
async Task<JsonNode?> SendRequest(HttpClient httpClient, int requestId)
{
var payload = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = requestId,
["method"] = "message/send",
["params"] = new JsonObject
{
["message"] = new JsonObject
{
["parts"] = new JsonArray(new JsonObject { ["text"] = $"Request #{requestId}" })
}
}
};
var resp = await httpClient.PostAsync(
$"{KubeMqUrl}/a2a/{AgentId}",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
return JsonNode.Parse(await resp.Content.ReadAsStringAsync());
}
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
Console.WriteLine($"Sending {NumRequests} concurrent requests (limit is 100)...");
var tasks = Enumerable.Range(1, NumRequests)
.Select(i => SendRequest(client, i))
.ToArray();
var results = await Task.WhenAll(tasks);
int successes = 0, concurrencyErrors = 0, otherErrors = 0;
foreach (var r in results)
{
if (r?["result"] != null)
successes++;
else if (r?["error"]?["code"]?.GetValue<int>() == -32603)
concurrencyErrors++;
else
otherErrors++;
}
Console.WriteLine($" Successes: {successes}");
Console.WriteLine($" Concurrency errors: {concurrencyErrors} (code -32603)");
Console.WriteLine($" Other errors: {otherErrors}");
if (concurrencyErrors >= 1)
Console.WriteLine($"\nConcurrency limit enforced — {concurrencyErrors} request(s) rejected!");package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
)
const (
kubemqURL = "http://localhost:9090"
agentID = "concurrency-agent-01"
numRequests = 101
)
type result struct {
data map[string]interface{}
err error
}
func sendRequest(id int, client *http.Client, wg *sync.WaitGroup, results chan<- result) {
defer wg.Done()
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": id,
"method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{
"parts": []map[string]interface{}{{"text": fmt.Sprintf("Request #%d", id)}},
},
},
}
data, _ := json.Marshal(payload)
resp, err := client.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(data))
if err != nil {
results <- result{err: err}
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var r map[string]interface{}
json.Unmarshal(body, &r)
results <- result{data: r}
}
func main() {
fmt.Printf("Sending %d concurrent requests (limit is 100)...\n", numRequests)
client := &http.Client{Timeout: 30 * time.Second}
results := make(chan result, numRequests)
var wg sync.WaitGroup
for i := 1; i <= numRequests; i++ {
wg.Add(1)
go sendRequest(i, client, &wg, results)
}
wg.Wait()
close(results)
successes, concurrencyErrors, otherErrors := 0, 0, 0
for r := range results {
if r.err != nil {
otherErrors++
} else if _, ok := r.data["result"]; ok {
successes++
} else if e, ok := r.data["error"].(map[string]interface{}); ok {
if code, _ := e["code"].(float64); int(code) == -32603 {
concurrencyErrors++
} else {
otherErrors++
}
}
}
fmt.Printf(" Successes: %d\n", successes)
fmt.Printf(" Concurrency errors: %d (code -32603)\n", concurrencyErrors)
fmt.Printf(" Other errors: %d\n", otherErrors)
if concurrencyErrors >= 1 {
fmt.Printf("\nConcurrency limit enforced — %d request(s) rejected!\n", concurrencyErrors)
}
}import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final String AGENT_ID = "concurrency-agent-01";
static final int NUM_REQUESTS = 101;
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
var client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.build();
System.out.println("Sending " + NUM_REQUESTS + " concurrent requests (limit is 100)...");
List<CompletableFuture<HttpResponse<String>>> futures = new ArrayList<>();
for (int i = 1; i <= NUM_REQUESTS; i++) {
var payload = Map.of(
"jsonrpc", "2.0",
"id", i,
"method", "message/send",
"params", Map.of(
"message", Map.of("parts", List.of(Map.of("text", "Request #" + i)))
)
);
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
futures.add(client.sendAsync(req, HttpResponse.BodyHandlers.ofString()));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
int successes = 0, concurrencyErrors = 0, otherErrors = 0;
for (var future : futures) {
var data = MAPPER.readTree(future.get().body());
if (data.has("result")) {
successes++;
} else if (data.has("error")) {
if (data.path("error").path("code").asInt() == -32603) concurrencyErrors++;
else otherErrors++;
}
}
System.out.println(" Successes: " + successes);
System.out.println(" Concurrency errors: " + concurrencyErrors + " (code -32603)");
System.out.println(" Other errors: " + otherErrors);
if (concurrencyErrors >= 1)
System.out.println("\nConcurrency limit enforced — " + concurrencyErrors + " request(s) rejected!");
}
}import asyncio
import httpx
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "concurrency-agent-01"
NUM_REQUESTS = 101
async def send_request(client: httpx.AsyncClient, request_id: int) -> dict:
payload = {
"jsonrpc": "2.0",
"id": request_id,
"method": "message/send",
"params": {
"message": {"parts": [{"text": f"Request #{request_id}"}]},
},
}
resp = await client.post(f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload)
return resp.json()
async def main() -> None:
limits = httpx.Limits(max_connections=200)
async with httpx.AsyncClient(timeout=30, limits=limits) as client:
print(f"Sending {NUM_REQUESTS} concurrent requests (limit is 100)...")
tasks = [send_request(client, i) for i in range(1, NUM_REQUESTS + 1)]
results = await asyncio.gather(*tasks)
successes = sum(1 for r in results if "result" in r)
concurrency_errors = sum(1 for r in results if r.get("error", {}).get("code") == -32603)
other_errors = len(results) - successes - concurrency_errors
print(f" Successes: {successes}")
print(f" Concurrency errors: {concurrency_errors} (code -32603)")
print(f" Other errors: {other_errors}")
assert concurrency_errors >= 1, "Expected at least 1 concurrency limit error"
print(f"\nConcurrency limit enforced — {concurrency_errors} request(s) rejected!")
if __name__ == "__main__":
asyncio.run(main())const KUBEMQ_URL = "http://localhost:9090";
const AGENT_ID = "concurrency-agent-01";
const NUM_REQUESTS = 101;
async function sendRequest(id: number): Promise<{ ok: boolean; errorCode?: number }> {
const request = {
jsonrpc: "2.0",
id,
method: "message/send",
params: { message: { parts: [{ text: `Request ${id}` }] } },
};
const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
const data = await resp.json();
if (data.error) {
return { ok: false, errorCode: data.error.code };
}
return { ok: true };
}
async function main() {
console.log(`Sending ${NUM_REQUESTS} concurrent requests (limit is 100)...`);
const promises = Array.from({ length: NUM_REQUESTS }, (_, i) => sendRequest(i + 1));
const results = await Promise.all(promises);
const succeeded = results.filter((r) => r.ok).length;
const rejected = results.filter((r) => r.errorCode === -32603).length;
const otherErrors = results.length - succeeded - rejected;
console.log(` Succeeded: ${succeeded} (expect <=100)`);
console.log(` Rejected -32603: ${rejected} (expect >=1)`);
console.log(` Other errors: ${otherErrors}`);
if (rejected > 0) {
console.log(`\nConcurrency limit enforced correctly.`);
}
}
main().catch(console.error);Response-size limit
The gateway caps the agent's HTTP response body at AgentMaxResponseBytes (default
10 MB). When an agent returns more than that — for a synchronous reply, or for a single
SSE event's accumulated data lines on a stream — the gateway aborts the relay to protect
itself from memory exhaustion and returns -32603:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32603,
"message": "internal error: response too large"
}
}At the transport layer this is Executed: false with the reason
agent response too large. Unlike a timeout, retrying the same request will fail
identically — the agent is producing an oversized body. Fix it by paginating the agent's
output, streaming the result via message/stream, or
raising AgentMaxResponseBytes if the payload is legitimately large.
Observe the limit
Target an agent that returns more than 10 MB and inspect the error object.
curl -s -X POST http://localhost:9090/a2a/oversize-agent-01 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {"message": {"parts": [{"text": "Give me a large response"}]}}
}'
# => {"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"internal error: response too large"}}using System.Text;
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
const string AgentId = "oversize-agent-01";
var payload = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = 1,
["method"] = "message/send",
["params"] = new JsonObject
{
["message"] = new JsonObject
{
["parts"] = new JsonArray(new JsonObject { ["text"] = "Give me a large response" })
}
}
};
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
Console.WriteLine("Requesting oversized response (>10MB)...");
var resp = await client.PostAsync(
$"{KubeMqUrl}/a2a/{AgentId}",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
var body = await resp.Content.ReadAsStringAsync();
var data = JsonNode.Parse(body)!;
if (data["error"] != null)
{
var error = data["error"]!;
Console.WriteLine($"Error code: {error["code"]}");
Console.WriteLine($"Error message: {error["message"]}");
Console.WriteLine("\nResponse size limit enforced!");
}
else
{
Console.WriteLine($"Status: {(int)resp.StatusCode}");
Console.WriteLine("Note: Response was accepted (check AgentMaxResponseBytes)");
}package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
const (
kubemqURL = "http://localhost:9090"
agentID = "oversize-agent-01"
)
func main() {
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{
"parts": []map[string]interface{}{{"text": "Give me a large response"}},
},
},
}
data, _ := json.Marshal(payload)
fmt.Println("Requesting oversized response (>10MB)...")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(data))
if err != nil {
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
json.Unmarshal(body, &result)
if errorObj, ok := result["error"].(map[string]interface{}); ok {
code, _ := errorObj["code"].(float64)
msg, _ := errorObj["message"].(string)
fmt.Printf("Error code: %.0f\n", code)
fmt.Printf("Error message: %s\n", msg)
fmt.Println("\nResponse size limit enforced!")
} else {
fmt.Printf("Status: %d\n", resp.StatusCode)
fmt.Println("Note: Response was accepted (check AgentMaxResponseBytes)")
}
}import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final String AGENT_ID = "oversize-agent-01";
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
var payload = Map.of(
"jsonrpc", "2.0",
"id", 1,
"method", "message/send",
"params", Map.of(
"message", Map.of("parts", List.of(Map.of("text", "Give me a large response")))
)
);
var client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build();
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
System.out.println("Requesting oversized response (>10MB)...");
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
var data = MAPPER.readTree(resp.body());
if (data.has("error")) {
var error = data.path("error");
System.out.println("Error code: " + error.path("code").asInt());
System.out.println("Error message: " + error.path("message").asText());
System.out.println("\nResponse size limit enforced!");
} else {
System.out.println("Status: " + resp.statusCode());
System.out.println("Note: Response was accepted (check AgentMaxResponseBytes)");
}
}
}import asyncio
import httpx
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "oversize-agent-01"
async def main() -> None:
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {"parts": [{"text": "Give me a large response"}]},
},
}
async with httpx.AsyncClient(timeout=30) as client:
print("Requesting oversized response (>10MB)...")
resp = await client.post(f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload)
data = resp.json()
if "error" in data:
error = data["error"]
print(f"Error code: {error.get('code')}")
print(f"Error message: {error.get('message')}")
print("\nResponse size limit enforced!")
else:
print(f"Status: {resp.status_code}")
print("Note: Response was accepted (check AgentMaxResponseBytes)")
if __name__ == "__main__":
asyncio.run(main())const KUBEMQ_URL = "http://localhost:9090";
const AGENT_ID = "oversize-agent-01";
async function main() {
const request = {
jsonrpc: "2.0",
id: 1,
method: "message/send",
params: {
message: { parts: [{ text: "Give me a large response" }] },
},
};
console.log("Requesting oversized response (>10MB)...");
const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
const data = await resp.json();
if (data.error) {
console.log(`Error code: ${data.error.code}`);
console.log(`Error message: ${data.error.message}`);
console.log("\nResponse size limit enforced!");
} else {
console.log("Unexpected: got success response. Response may have been under the limit.");
}
}
main().catch(console.error);Timeout capping
Each call carries a deadline. When a client sets params.configuration.timeout (in
seconds), the gateway clamps it to the configured window before proxying to the agent:
- If no timeout is supplied, the server applies
DefaultTimeoutSeconds(300s). - Any value above
MaxTimeoutSeconds(3600s) is silently reduced to that ceiling. - When the deadline passes before the agent replies, the call returns
-32001(agent timeout).
params.configuration.timeout | Effective deadline |
|---|---|
| not set | DefaultTimeoutSeconds (300s) |
30 | 30s |
3600 | 3600s |
99999 | 3600s (capped to MaxTimeoutSeconds) |
A gateway buffer of ~10 seconds (GatewayTimeoutBuffer) is added on top of the
effective deadline before the gateway times out its proxy request, so the gateway never
gives up before the agent's own deadline. Give your HTTP client a slightly longer timeout
than the value you request (a 15s pad is typical) so the client does not abort before the
gateway returns the -32001 envelope.
Monitoring
Concurrency and limit pressure are visible in the Prometheus metrics exported on port 8080 — see Observability.
| Metric | What it tells you |
|---|---|
kubemq_a2a_requests_total | Total requests per agent and method — rising rejections track concurrency pressure |
kubemq_a2a_errors_total | Error count per agent — -32603 spikes signal a saturated agent |
kubemq_a2a_sse_streams_active | Active SSE streams per agent (each counts against the concurrency budget) |
Related
Configuration
Tune AgentMaxConcurrency, AgentMaxResponseBytes, and the timeout fields in A2aConfig.
Error handling
The -32603 / -32001 codes and the Executed transport vs application error model.
SSE behavior
Streaming idle timeouts and how active streams count against the concurrency cap.
Observability
Prometheus metrics for requests, errors, and active SSE streams per agent.
Was this page helpful?
Authentication
Secure A2A gateway and registry calls with JWT Bearer tokens — agent ownership, caller identity, and why the agent never sees your token.
SSE Behavior
Reference for the A2A Server-Sent Events wire protocol — event types, the data envelope, keepalive comments, idle timeout, and client-disconnect cancellation.