Skip to content

Commit 45b91ff

Browse files
danielmillerpclaude
andcommitted
fix(streams): cut SSE error-log volume and add Redis pool headroom
The task-event SSE endpoint holds one blocking XREAD connection per connected client. When concurrent streams exceed the Redis connection pool size, every client's read fails on each cycle. Combined with plain-text logging that splits each traceback into one log entry per line, and a flat 1s retry, a sustained pool stall becomes a log-ingestion firehose. Changes: - Streaming error path: replace the flat 1s retry with capped exponential backoff (1->2->4->8->16->30s), reset on a healthy read, so a tight per-client loop can't hammer Redis or flood logs. Full tracebacks are still logged on every failure (nothing swallowed) with a failure counter for context; volume is bounded by the backoff and by single-entry JSON logging below. - Default to structured JSON logs in all deployed environments (ENVIRONMENT != "development"). JSON keeps a multi-line traceback as a single log entry (newlines live inside the quoted exc_info field) instead of one cluster-log entry per line. Local development keeps plain text for readable console output. The JSON formatter already existed but was gated behind Datadog configuration. - Bump the in-code REDIS_MAX_CONNECTIONS default (50 -> 200) for peak-concurrency headroom. This is a mitigation, not a fix: connections still scale 1:1 with clients. The durable fix is a shared per-pod reader that fans out to in-process queues. Deployed environments set the real cap via the REDIS_MAX_CONNECTIONS env var. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8c060f1 commit 45b91ff

3 files changed

Lines changed: 43 additions & 5 deletions

File tree

agentex/src/config/environment_variables.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,13 @@ class EnvironmentVariables(BaseModel):
9696
MONGODB_DATABASE_NAME: str | None = "agentex"
9797
MONGODB_MAX_POOL_SIZE: int = 50
9898
MONGODB_MIN_POOL_SIZE: int = 5
99-
REDIS_MAX_CONNECTIONS: int = 50 # Increased for SSE streaming
99+
# SSE streaming currently holds one blocking XREAD connection per connected
100+
# client, so the pool needs headroom for peak concurrent streams per pod.
101+
# NOTE: this is only the in-code default — deployed environments override it
102+
# via the REDIS_MAX_CONNECTIONS env var, which is the real cap. Bumping this
103+
# buys headroom but does NOT change the 1-connection-per-client scaling; the
104+
# durable fix is a shared per-pod reader that fans out to in-process queues.
105+
REDIS_MAX_CONNECTIONS: int = 200
100106
REDIS_CONNECTION_TIMEOUT: int = 60 # Connection timeout in seconds
101107
REDIS_SOCKET_TIMEOUT: int = 30 # Socket timeout in seconds
102108
REDIS_STREAM_MAXLEN: int = (

agentex/src/domain/use_cases/streams_use_case.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@ async def stream_task_events(
114114
ping_interval = float(
115115
self.environment_variables.SSE_KEEPALIVE_PING_INTERVAL
116116
) # Configurable keepalive ping interval
117+
# Track consecutive read failures so we can back off and avoid a
118+
# tight error loop. When the Redis pool is exhausted, every connected
119+
# client's read fails on each cycle; without backoff this turns into a
120+
# log-ingestion firehose (one failure per client per cycle, ~once/sec).
121+
consecutive_errors = 0
117122
try:
118123
# Application-level control loop
119124
while True:
@@ -133,6 +138,10 @@ async def stream_task_events(
133138
last_message_time = asyncio.get_running_loop().time()
134139
await asyncio.sleep(0.02)
135140

141+
# A read cycle completed without raising — the stream is
142+
# healthy again, so reset the backoff/error counter.
143+
consecutive_errors = 0
144+
136145
# If we didn't get any messages, add a small pause
137146
# to prevent tight loops and send keepalive ping if needed
138147
if message_count == 0:
@@ -151,13 +160,25 @@ async def stream_task_events(
151160
)
152161
raise
153162
except Exception as e:
163+
consecutive_errors += 1
164+
# Always log the full traceback — nothing is swallowed.
165+
# Volume is controlled two ways instead of by dropping
166+
# diagnostics: structured JSON logging keeps each traceback
167+
# to a single log entry (see utils.logging), and the
168+
# exponential backoff below caps how often a sustained
169+
# failure can repeat. The failure counter gives context on
170+
# how long a stream has been erroring.
154171
logger.error(
155-
f"Error processing events for task {task_id}: {e}",
172+
f"Error processing events for task {task_id} "
173+
f"(failure #{consecutive_errors}): {e}",
156174
exc_info=True,
157175
)
158176
yield f"data: {TaskStreamErrorEventEntity(type='error', message=str(e)).model_dump_json()}\n\n"
159-
# Add a small delay before continuing
160-
await asyncio.sleep(1)
177+
# Exponential backoff (capped) so a sustained failure (e.g.
178+
# Redis pool exhaustion) doesn't spin a tight per-client
179+
# loop hammering Redis and flooding logs.
180+
backoff = min(2.0 ** min(consecutive_errors - 1, 5), 30.0)
181+
await asyncio.sleep(backoff)
161182

162183
except asyncio.CancelledError:
163184
# Just exit the generator on cancellation

agentex/src/utils/logging.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@
1414
# Check if Datadog is configured
1515
_is_datadog_configured = bool(os.environ.get("DD_AGENT_HOST"))
1616

17+
# Emit structured JSON logs in all deployed environments. JSON keeps a
18+
# multi-line traceback (exc_info=True) as a single log entry — the newlines
19+
# live inside the quoted `exc_info` field — instead of fanning out into one
20+
# cluster-log entry per traceback line. Splitting tracebacks per line was a
21+
# primary multiplier behind a log-ingestion spike on a plain-text-logging
22+
# cluster. Local development keeps plain text for readable console output;
23+
# JSON is the default everywhere else (including when ENVIRONMENT is unset)
24+
# so a deployed cluster can never silently fall back to per-line tracebacks.
25+
_is_local_dev = os.environ.get("ENVIRONMENT", "").lower() == "development"
26+
_use_json_logs = not _is_local_dev
27+
1728
# Include Datadog trace IDs only when Datadog is configured
1829
if _is_datadog_configured:
1930
LOG_FORMAT: str = (
@@ -134,7 +145,7 @@ def make_logger(name: str) -> logging.Logger:
134145

135146
logger = logging.getLogger(name)
136147
stream_handler = logging.StreamHandler()
137-
if _is_datadog_configured:
148+
if _use_json_logs:
138149
stream_handler.setFormatter(CustomJSONFormatter())
139150
else:
140151
stream_handler.setFormatter(logging.Formatter(LOG_FORMAT))

0 commit comments

Comments
 (0)