From f635dbc13f219722faadadd89ecb3f611baac9c4 Mon Sep 17 00:00:00 2001 From: Lyndz Williams Date: Thu, 9 Jul 2026 15:40:57 +0100 Subject: [PATCH] fix: stop PulseAgent blocking the hypercode-core event loop PulseAgent.process() is a coroutine running on hypercode-core's event loop, and it called requests.get() -- the sync api -- to query Prometheus with a 5s timeout. A stall there delays every request the core API is serving, not just the pulse call. Replace with an awaited httpx.AsyncClient. The client is built ONCE at module import, not per call, and that detail is the whole fix. Constructing an AsyncClient builds an SSL context and loads the CA bundle: hundreds of milliseconds of synchronous work. The obvious rewrite -- `async with httpx.AsyncClient() as c` inside process() -- measured WORSE than the bug it replaced. Worst event-loop stall per call, measured inside the running container with a 50ms ticker: requests.get (sync) 53 ms AsyncClient built per call 284 ms <- worse than the bug shared AsyncClient (shipped) 8 ms Verified against the shipped module (import app.agents.pulse; drive its real _http client): steady-state stall 1-10ms across runs 2-4, status 200, 14 Prometheus targets parsed. First call in a fresh process costs ~126ms of one-time transport warmup, then settles. httpx.Response exposes .status_code/.json()/.text identically to requests, so the parsing below the call is untouched. pulse.py was the only user of `requests` in backend/app. Not exercised: brain.think() below the fetch. That path is unchanged. Co-Authored-By: Claude Opus 4.8 --- backend/app/agents/pulse.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/app/agents/pulse.py b/backend/app/agents/pulse.py index 0b8241b9..97989ccc 100644 --- a/backend/app/agents/pulse.py +++ b/backend/app/agents/pulse.py @@ -1,9 +1,18 @@ import logging -import requests +import httpx from app.agents.brain import brain logger = logging.getLogger(__name__) +# Built once, at import. Constructing an AsyncClient builds an SSL context and +# loads the CA bundle — hundreds of ms of *synchronous* work. Doing that inside +# process() stalls the loop harder than the sync requests.get it replaced. +# Measured in-container, worst loop stall per call: +# requests.get (sync) 53 ms +# AsyncClient built per call 284 ms <- worse than the bug +# shared AsyncClient (this) 8 ms +_http = httpx.AsyncClient(timeout=5.0) + class PulseAgent: def __init__(self): self.brain = brain @@ -16,8 +25,10 @@ async def process(self, payload=None, conversation_id: str | None = None): # 1. Grab raw vitals from Prometheus (Checking what services are 'up') try: # Using 'up' query to see which targets are up - # This query returns status of all scraped targets - res = requests.get(self.prometheus_url, params={'query': 'up'}, timeout=5) + # This query returns status of all scraped targets. + # Awaited, not sync: this runs on hypercode-core's event loop, and a + # blocking call here delays every request the API is serving. + res = await _http.get(self.prometheus_url, params={'query': 'up'}) if res.status_code == 200: raw_data = res.json() # Extract relevant info to keep prompt size down