From 1c987bd42a92ba2bf7849558515335db26cdf730 Mon Sep 17 00:00:00 2001 From: jariy17 Date: Tue, 4 Aug 2026 18:35:01 +0000 Subject: [PATCH] fix(a2a): bind the A2A contract port, ignore generic PORT serve_a2a() resolved its port from the generic PORT environment variable (#593), but the AgentCore Runtime A2A service contract fixes the container port at 9000 (HTTP is 8080, MCP is 8000). PORT is a widespread convention and is commonly already set to another protocol's port -- notably in an image shared across an HTTP and an A2A runtime, where PORT=8080 is correct for HTTP and fatal for A2A. When PORT was set to anything other than 9000, the A2A server bound there instead. Nothing listened on 9000, the runtime frontend's proxied connection was never answered, and every invocation failed with HTTP 424 (RuntimeClientError) after a client-side read timeout. The container started cleanly and logged no error, since the process was healthy and merely listening on the wrong port. Read the protocol-scoped A2A_PORT instead, which cannot collide with another protocol's port, and warn when the resolved port is not 9000 -- that configuration cannot work in a deployed runtime, so the previous silence was the expensive part of this failure. Precedence is unchanged for explicit callers: port= argument, then A2A_PORT, then 9000. --- docs/examples/a2a_protocol_examples.md | 25 ++++++++++- src/bedrock_agentcore/runtime/a2a.py | 27 ++++++++++-- tests/bedrock_agentcore/runtime/test_a2a.py | 46 ++++++++++++++++++--- 3 files changed, 88 insertions(+), 10 deletions(-) diff --git a/docs/examples/a2a_protocol_examples.md b/docs/examples/a2a_protocol_examples.md index 069620d6..3fa0c7a7 100644 --- a/docs/examples/a2a_protocol_examples.md +++ b/docs/examples/a2a_protocol_examples.md @@ -137,7 +137,7 @@ Starts a Bedrock-compatible A2A server with `uvicorn`. |-----------|------|---------|-------------| | `executor` | `AgentExecutor` | required | An a2a-sdk `AgentExecutor` that implements the agent logic | | `agent_card` | `AgentCard` | `None` | Agent metadata. Auto-built from executor if omitted (works best with Strands) | -| `port` | `int \| None` | `None` | Port to serve on. Uses `PORT`, then `9000`, when omitted | +| `port` | `int \| None` | `None` | Port to serve on. Uses `A2A_PORT`, then `9000`, when omitted. The generic `PORT` is ignored — see [Ports](#ports) | | `host` | `str` | `None` | Host to bind to. Auto-detected: `0.0.0.0` in Docker, `127.0.0.1` otherwise | | `task_store` | `TaskStore` | `None` | Custom task store; defaults to `InMemoryTaskStore` | | `context_builder` | `CallContextBuilder` | `None` | Custom context builder; defaults to `BedrockCallContextBuilder` | @@ -178,6 +178,29 @@ Headers extracted: ## Behavior Details +### Ports + +The AgentCore Runtime service contract fixes the A2A container port at **9000** (HTTP uses 8080, MCP uses 8000). The +runtime proxies invocations to 9000 only, so an A2A server bound elsewhere is unreachable and every invocation fails +with **HTTP 424** (`RuntimeClientError`). + +`serve_a2a` therefore defaults to 9000 and **ignores the generic `PORT` environment variable**. `PORT` is a widespread +convention (Heroku, Cloud Run, App Runner) and is commonly already set to another protocol's port — notably in images +shared across an HTTP and an A2A runtime, where `PORT=8080` is correct for HTTP and fatal for A2A. + +To override the port for local development, use the protocol-scoped `A2A_PORT`, or pass `port=` explicitly: + +```bash +A2A_PORT=9001 python main.py +``` + +```python +serve_a2a(executor, port=9001) +``` + +Precedence is `port=` argument, then `A2A_PORT`, then 9000. When the resolved port is not 9000, `serve_a2a` logs a +warning, since that configuration cannot work in a deployed runtime. + ### Agent Card Auto-Population When deployed on Bedrock AgentCore, the `AGENTCORE_RUNTIME_URL` environment variable is set automatically. The agent card's `url` field is updated to match, so you don't need to hardcode the deployed URL. diff --git a/src/bedrock_agentcore/runtime/a2a.py b/src/bedrock_agentcore/runtime/a2a.py index ad6a4204..720e0791 100644 --- a/src/bedrock_agentcore/runtime/a2a.py +++ b/src/bedrock_agentcore/runtime/a2a.py @@ -28,6 +28,12 @@ logger = logging.getLogger(__name__) +# The AgentCore Runtime A2A service contract fixes the container port at 9000 +# (HTTP is 8080, MCP is 8000). It is not negotiable, so it is only overridable +# via an explicit ``port=`` argument or the protocol-scoped env var below. +A2A_CONTRACT_PORT = 9000 +A2A_PORT_ENV = "A2A_PORT" + def _check_a2a_sdk() -> None: """Raise ImportError with install instructions if a2a-sdk is missing.""" @@ -275,7 +281,7 @@ def build_a2a_app( from starlette.routing import Route runtime_url_override = os.environ.get(AGENTCORE_RUNTIME_URL_ENV) or runtime_url - advertised_url = runtime_url_override or "http://localhost:9000/" + advertised_url = runtime_url_override or f"http://localhost:{A2A_CONTRACT_PORT}/" is_a2a_v1 = _is_a2a_v1() if agent_card is None: @@ -354,8 +360,11 @@ def serve_a2a( executor: An ``AgentExecutor`` that implements the agent logic. agent_card: Optional ``a2a.types.AgentCard`` describing the agent. If ``None``, one is built automatically by introspecting the executor. - port: Port to serve on. Defaults to the ``PORT`` environment variable, - or 9000 when it is unset. + port: Port to serve on. Defaults to the ``A2A_PORT`` environment + variable, or 9000 when it is unset. ``PORT`` is deliberately not + consulted: the AgentCore A2A contract fixes the port at 9000, and + ``PORT`` is widely set to another protocol's port (8080 for HTTP, + 8000 for MCP) in images shared across runtimes. host: Host to bind to; auto-detected if ``None``. task_store: Optional ``TaskStore``; defaults to ``InMemoryTaskStore``. context_builder: Optional ``ServerCallContextBuilder``; defaults to @@ -367,7 +376,17 @@ def serve_a2a( import uvicorn - resolved_port = port if port is not None else int(os.environ.get("PORT", "9000")) + resolved_port = port if port is not None else int(os.environ.get(A2A_PORT_ENV, A2A_CONTRACT_PORT)) + + if resolved_port != A2A_CONTRACT_PORT: + logger.warning( + "A2A server binding port %d, but the AgentCore Runtime A2A service contract " + "requires %d. Deployed invocations will fail with HTTP 424 (RuntimeClientError) " + "because the runtime proxies to %d only.", + resolved_port, + A2A_CONTRACT_PORT, + A2A_CONTRACT_PORT, + ) app = build_a2a_app( executor, diff --git a/tests/bedrock_agentcore/runtime/test_a2a.py b/tests/bedrock_agentcore/runtime/test_a2a.py index dd3c0062..3bdc04a2 100644 --- a/tests/bedrock_agentcore/runtime/test_a2a.py +++ b/tests/bedrock_agentcore/runtime/test_a2a.py @@ -1,4 +1,5 @@ import contextvars +import logging import uuid from unittest.mock import patch @@ -456,7 +457,7 @@ def test_default_localhost(self, mock_uvicorn_run): with patch.dict("os.environ", {}, clear=False): import os - os.environ.pop("PORT", None) + os.environ.pop("A2A_PORT", None) with patch("os.path.exists", return_value=False): serve_a2a(_EchoExecutor(), _make_agent_card()) kw = mock_uvicorn_run.call_args[1] @@ -465,7 +466,7 @@ def test_default_localhost(self, mock_uvicorn_run): @patch("uvicorn.run") def test_port_from_environment(self, mock_uvicorn_run): - with patch.dict("os.environ", {"PORT": "9001"}, clear=True): + with patch.dict("os.environ", {"A2A_PORT": "9001"}, clear=True): serve_a2a(_EchoExecutor()) app = mock_uvicorn_run.call_args.args[0] @@ -475,7 +476,7 @@ def test_port_from_environment(self, mock_uvicorn_run): @patch("uvicorn.run") def test_port_from_environment_updates_explicit_card(self, mock_uvicorn_run): - with patch.dict("os.environ", {"PORT": "9002"}, clear=True): + with patch.dict("os.environ", {"A2A_PORT": "9002"}, clear=True): serve_a2a(_EchoExecutor(), _make_agent_card()) app = mock_uvicorn_run.call_args.args[0] @@ -485,7 +486,7 @@ def test_port_from_environment_updates_explicit_card(self, mock_uvicorn_run): @patch("uvicorn.run") def test_explicit_port_overrides_environment(self, mock_uvicorn_run): - with patch.dict("os.environ", {"PORT": "9001"}): + with patch.dict("os.environ", {"A2A_PORT": "9001"}): serve_a2a(_EchoExecutor(), _make_agent_card(), port=8888) app = mock_uvicorn_run.call_args.args[0] response = TestClient(app).get("/.well-known/agent-card.json") @@ -497,7 +498,7 @@ def test_runtime_url_environment_overrides_port_for_card(self, mock_uvicorn_run) with patch.dict( "os.environ", { - "PORT": "9002", + "A2A_PORT": "9002", "AGENTCORE_RUNTIME_URL": "https://deployed.example.com/", }, clear=True, @@ -509,6 +510,41 @@ def test_runtime_url_environment_overrides_port_for_card(self, mock_uvicorn_run) assert mock_uvicorn_run.call_args.kwargs["port"] == 9002 assert _card_response_url(response.json()) == "https://deployed.example.com/" + @patch("uvicorn.run") + def test_generic_port_env_var_is_ignored(self, mock_uvicorn_run): + """PORT must not affect the A2A port: shared images set it to 8080 for HTTP.""" + with patch.dict("os.environ", {"PORT": "8080"}, clear=True): + serve_a2a(_EchoExecutor(), _make_agent_card()) + + app = mock_uvicorn_run.call_args.args[0] + response = TestClient(app).get("/.well-known/agent-card.json") + assert mock_uvicorn_run.call_args.kwargs["port"] == 9000 + assert _card_response_url(response.json()) == "http://localhost:9000/" + + @patch("uvicorn.run") + def test_a2a_port_takes_precedence_over_generic_port(self, mock_uvicorn_run): + with patch.dict("os.environ", {"PORT": "8080", "A2A_PORT": "9003"}, clear=True): + serve_a2a(_EchoExecutor(), _make_agent_card()) + + assert mock_uvicorn_run.call_args.kwargs["port"] == 9003 + + @patch("uvicorn.run") + def test_warns_when_binding_non_contract_port(self, mock_uvicorn_run, caplog): + with patch.dict("os.environ", {}, clear=True): + with caplog.at_level(logging.WARNING, logger="bedrock_agentcore.runtime.a2a"): + serve_a2a(_EchoExecutor(), _make_agent_card(), port=8888) + + assert "requires 9000" in caplog.text + assert "424" in caplog.text + + @patch("uvicorn.run") + def test_no_warning_on_contract_port(self, mock_uvicorn_run, caplog): + with patch.dict("os.environ", {}, clear=True): + with caplog.at_level(logging.WARNING, logger="bedrock_agentcore.runtime.a2a"): + serve_a2a(_EchoExecutor(), _make_agent_card()) + + assert caplog.text == "" + @patch("uvicorn.run") def test_docker_detection_dockerenv(self, mock_uvicorn_run): with patch("os.path.exists", return_value=True):