Skip to content

Commit 3db9365

Browse files
committed
Address greptile: decouple the client timeouts from EnvironmentVariables
Greptile flagged that adding the four timeout fields to the shared EnvironmentVariables model meant a malformed value broke far more than the client factory: refresh() is called from ~20 unguarded places, including AgentWorker startup and EnvAuth.auth_flow on every request. The try/except in create_async_agentex_client() did not contain that, it only made it look handled. Removing the try/except alone made it worse. agentex/lib/adk/utils/__init__.py constructs TemplatingModule() at module scope, which builds a client, so importing the ADK started requiring AGENT_NAME and ACP_URL to be set. The suppressed exception had been hiding that. Read the four values from os.environ in client.py instead. The shared model is untouched, so startup, auth and import are unaffected, and a malformed value raises a ValueError naming the variable at the point it is used rather than being swallowed. Adds a regression test asserting the timeout does not depend on the shared model.
1 parent e02c23d commit 3db9365

3 files changed

Lines changed: 73 additions & 65 deletions

File tree

src/agentex/lib/adk/utils/_modules/client.py

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
from typing import override
23

34
import httpx
@@ -26,21 +27,40 @@ def auth_flow(self, request):
2627
yield request
2728

2829

30+
# HTTP timeouts for the AgentEx client, in seconds. Defaults match the SDK's
31+
# DEFAULT_TIMEOUT, so leaving these unset changes nothing.
32+
_TIMEOUT_ENV_DEFAULTS = {
33+
"connect": ("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", 5.0),
34+
"read": ("AGENTEX_CLIENT_READ_TIMEOUT_SECONDS", 300.0),
35+
"write": ("AGENTEX_CLIENT_WRITE_TIMEOUT_SECONDS", 300.0),
36+
"pool": ("AGENTEX_CLIENT_POOL_TIMEOUT_SECONDS", 300.0),
37+
}
38+
39+
2940
def _timeout_from_env() -> httpx.Timeout:
3041
"""Build the client timeout from environment variables.
3142
32-
Defaults match the SDK's DEFAULT_TIMEOUT, so an unconfigured process behaves
33-
exactly as before. The connect timeout is the one worth raising: an AgentEx
34-
backend accepts connections serially, so connect latency grows with the number
35-
of callers and the 5s default is reached when a few hundred are in flight.
43+
Read from ``os.environ`` rather than from ``EnvironmentVariables``. That model
44+
is loaded by worker startup and by ``EnvAuth.auth_flow`` on every request, and
45+
``agentex.lib.adk.utils`` builds a client at import time, so a field added
46+
there would make a malformed timeout break all three. Reading here keeps the
47+
blast radius to the one value that is actually wrong.
48+
49+
The connect timeout is the one worth raising: an AgentEx backend accepts
50+
connections serially, so connect latency grows with the number of callers and
51+
the 5s default is reached when a few hundred are in flight.
3652
"""
37-
env_vars = EnvironmentVariables.refresh()
38-
return httpx.Timeout(
39-
connect=env_vars.AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS,
40-
read=env_vars.AGENTEX_CLIENT_READ_TIMEOUT_SECONDS,
41-
write=env_vars.AGENTEX_CLIENT_WRITE_TIMEOUT_SECONDS,
42-
pool=env_vars.AGENTEX_CLIENT_POOL_TIMEOUT_SECONDS,
43-
)
53+
values = {}
54+
for field, (env_var, default) in _TIMEOUT_ENV_DEFAULTS.items():
55+
raw = os.environ.get(env_var)
56+
if raw is None or raw.strip() == "":
57+
values[field] = default
58+
continue
59+
try:
60+
values[field] = float(raw)
61+
except ValueError as exc:
62+
raise ValueError(f"{env_var} must be a number in seconds, got {raw!r}") from exc
63+
return httpx.Timeout(**values)
4464

4565

4666
def create_async_agentex_client(**kwargs) -> AsyncAgentex:
@@ -50,11 +70,7 @@ def create_async_agentex_client(**kwargs) -> AsyncAgentex:
5070
AGENTEX_CLIENT_*_TIMEOUT_SECONDS environment variables.
5171
"""
5272
if "timeout" not in kwargs:
53-
try:
54-
kwargs["timeout"] = _timeout_from_env()
55-
except Exception as exc:
56-
# Never let timeout configuration stop a client being created.
57-
logger.warning("Falling back to SDK default timeout: %r", exc)
73+
kwargs["timeout"] = _timeout_from_env()
5874
client = AsyncAgentex(**kwargs)
5975
client._client.auth = EnvAuth()
6076
return client

src/agentex/lib/environment_variables.py

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,12 @@ class EnvVarKeys(str, Enum):
2020
TEMPORAL_ADDRESS = "TEMPORAL_ADDRESS"
2121
REDIS_URL = "REDIS_URL"
2222
AGENTEX_BASE_URL = "AGENTEX_BASE_URL"
23-
# AgentEx client HTTP timeouts (seconds)
24-
AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS = "AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS"
25-
AGENTEX_CLIENT_READ_TIMEOUT_SECONDS = "AGENTEX_CLIENT_READ_TIMEOUT_SECONDS"
26-
AGENTEX_CLIENT_WRITE_TIMEOUT_SECONDS = "AGENTEX_CLIENT_WRITE_TIMEOUT_SECONDS"
27-
AGENTEX_CLIENT_POOL_TIMEOUT_SECONDS = "AGENTEX_CLIENT_POOL_TIMEOUT_SECONDS"
2823
# Agent Identifiers
2924
AGENT_NAME = "AGENT_NAME"
3025
AGENT_DESCRIPTION = "AGENT_DESCRIPTION"
3126
AGENT_ID = "AGENT_ID"
3227
AGENT_VERSION = "AGENT_VERSION"
28+
AGENT_COMMIT_SHA = "AGENT_COMMIT_SHA"
3329
AGENT_API_KEY = "AGENT_API_KEY"
3430
# ACP Configuration
3531
ACP_URL = "ACP_URL"
@@ -66,20 +62,18 @@ class EnvironmentVariables(BaseModel):
6662
TEMPORAL_ADDRESS: str | None = "localhost:7233"
6763
REDIS_URL: str | None = None
6864
AGENTEX_BASE_URL: str | None = "http://localhost:5003"
69-
# HTTP timeouts for the AgentEx client, in seconds. Defaults match the
70-
# SDK's DEFAULT_TIMEOUT, so leaving these unset changes nothing.
71-
# Raise the connect timeout when many concurrent activities share one
72-
# backend: accepts queue, and 5s is reached at a few hundred in flight.
73-
AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS: float = 5.0
74-
AGENTEX_CLIENT_READ_TIMEOUT_SECONDS: float = 300.0
75-
AGENTEX_CLIENT_WRITE_TIMEOUT_SECONDS: float = 300.0
76-
AGENTEX_CLIENT_POOL_TIMEOUT_SECONDS: float = 300.0
7765
# Agent Identifiers
7866
AGENT_NAME: str
7967
AGENT_DESCRIPTION: str | None = None
8068
AGENT_ID: str | None = None
8169
# Build/version discriminator (image tag or git sha), set by the deployment
8270
AGENT_VERSION: str | None = None
71+
# The agent's source commit, baked into the image or set by the deployment.
72+
# Unlike AGENT_VERSION this is expected to be a git SHA and nothing else, and
73+
# it is OPT-IN: nothing is stamped unless the agent calls
74+
# `adk.code_revision.enable()`, which also refuses a value that is not a git
75+
# object name. See agentex.lib.core.tracing.code_revision.
76+
AGENT_COMMIT_SHA: str | None = None
8377
AGENT_API_KEY: str | None = None
8478
ACP_TYPE: str | None = "async"
8579
AGENT_INPUT_TYPE: str | None = None

tests/lib/test_client_timeout_env.py

Lines changed: 34 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -13,32 +13,14 @@
1313
import httpx
1414
import pytest
1515

16-
import agentex.lib.environment_variables as env_module
1716
from agentex.lib.adk.utils._modules.client import (
1817
_timeout_from_env,
1918
create_async_agentex_client,
2019
)
2120

2221

23-
@pytest.fixture(autouse=True)
24-
def _clear_env_cache():
25-
"""EnvironmentVariables.refresh() memoises into a module global."""
26-
env_module.refreshed_environment_variables = None
27-
yield
28-
env_module.refreshed_environment_variables = None
29-
30-
31-
def _set_env(monkeypatch, **overrides: str) -> None:
32-
# EnvironmentVariables has required fields; set them so construction succeeds.
33-
monkeypatch.setenv("AGENT_NAME", "test-agent")
34-
monkeypatch.setenv("ACP_URL", "http://localhost:8000")
35-
for key, value in overrides.items():
36-
monkeypatch.setenv(key, value)
37-
38-
39-
def test_defaults_match_the_sdk_default_timeout(monkeypatch):
22+
def test_defaults_match_the_sdk_default_timeout():
4023
"""An unconfigured process must behave exactly as it did before."""
41-
_set_env(monkeypatch)
4224
timeout = _timeout_from_env()
4325
assert timeout.connect == 5.0
4426
assert timeout.read == 300.0
@@ -47,21 +29,18 @@ def test_defaults_match_the_sdk_default_timeout(monkeypatch):
4729

4830

4931
def test_connect_timeout_is_configurable(monkeypatch):
50-
_set_env(monkeypatch, AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS="30")
32+
monkeypatch.setenv("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", "30")
5133
timeout = _timeout_from_env()
5234
assert timeout.connect == 30.0
5335
# the others are untouched
5436
assert timeout.read == 300.0
5537

5638

5739
def test_all_four_are_configurable(monkeypatch):
58-
_set_env(
59-
monkeypatch,
60-
AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS="30",
61-
AGENTEX_CLIENT_READ_TIMEOUT_SECONDS="120",
62-
AGENTEX_CLIENT_WRITE_TIMEOUT_SECONDS="90",
63-
AGENTEX_CLIENT_POOL_TIMEOUT_SECONDS="60",
64-
)
40+
monkeypatch.setenv("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", "30")
41+
monkeypatch.setenv("AGENTEX_CLIENT_READ_TIMEOUT_SECONDS", "120")
42+
monkeypatch.setenv("AGENTEX_CLIENT_WRITE_TIMEOUT_SECONDS", "90")
43+
monkeypatch.setenv("AGENTEX_CLIENT_POOL_TIMEOUT_SECONDS", "60")
6544
timeout = _timeout_from_env()
6645
assert (timeout.connect, timeout.read, timeout.write, timeout.pool) == (
6746
30.0,
@@ -71,14 +50,21 @@ def test_all_four_are_configurable(monkeypatch):
7150
)
7251

7352

53+
def test_an_empty_value_falls_back_to_the_default():
54+
"""An unset variable and one set to the empty string mean the same thing."""
55+
with pytest.MonkeyPatch.context() as mp:
56+
mp.setenv("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", "")
57+
assert _timeout_from_env().connect == 5.0
58+
59+
7460
def test_client_picks_up_the_env_timeout(monkeypatch):
75-
_set_env(monkeypatch, AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS="30")
61+
monkeypatch.setenv("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", "30")
7662
client = create_async_agentex_client(api_key="test", base_url="http://localhost:5003")
7763
assert client.timeout.connect == 30.0
7864

7965

8066
def test_explicit_timeout_wins_over_the_environment(monkeypatch):
81-
_set_env(monkeypatch, AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS="30")
67+
monkeypatch.setenv("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", "30")
8268
client = create_async_agentex_client(
8369
api_key="test",
8470
base_url="http://localhost:5003",
@@ -87,15 +73,27 @@ def test_explicit_timeout_wins_over_the_environment(monkeypatch):
8773
assert client.timeout.connect == 7.0
8874

8975

90-
def test_env_auth_is_still_attached(monkeypatch):
76+
def test_env_auth_is_still_attached():
9177
"""The factory's original job must survive the change."""
92-
_set_env(monkeypatch)
9378
client = create_async_agentex_client(api_key="test", base_url="http://localhost:5003")
9479
assert client._client.auth is not None
9580

9681

97-
def test_a_bad_value_does_not_prevent_client_creation(monkeypatch):
98-
"""Timeout configuration must never be the reason a client fails to build."""
99-
_set_env(monkeypatch, AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS="not-a-number")
100-
client = create_async_agentex_client(api_key="test", base_url="http://localhost:5003")
101-
assert client is not None
82+
def test_a_bad_value_names_the_variable(monkeypatch):
83+
"""A malformed value is a configuration error, so it must not be swallowed."""
84+
monkeypatch.setenv("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", "not-a-number")
85+
with pytest.raises(ValueError, match="AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS"):
86+
_timeout_from_env()
87+
88+
89+
def test_the_timeout_does_not_depend_on_the_shared_environment_model(monkeypatch):
90+
"""Regression: these must not become EnvironmentVariables fields.
91+
92+
That model has required fields, is loaded by worker startup and by
93+
EnvAuth.auth_flow on every request, and agentex.lib.adk.utils builds a
94+
client at import time. Routing timeouts through it makes all three depend
95+
on a fully configured environment.
96+
"""
97+
monkeypatch.delenv("AGENT_NAME", raising=False)
98+
monkeypatch.delenv("ACP_URL", raising=False)
99+
assert _timeout_from_env().connect == 5.0

0 commit comments

Comments
 (0)