Skip to content

fix(http): balance _active_requests in response-consuming helpers (#12981) - #12989

Merged
mrveiss merged 2 commits into
Dev_new_guifrom
issue-12981
Jul 29, 2026
Merged

mrveiss merged 2 commits into
Dev_new_guifrom
issue-12981

Conversation

@mrveiss

@mrveiss mrveiss commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Thinking Path

HTTPClientManager.request() increments _active_requests on every call but only decrements it on the failure path, delegating the success-path decrement to the caller. Repo-wide, exactly one production caller honoured that contract (autobot-backend/chat_workflow/manager.py:2147, a streaming path) against ~151 shared-client call sites. get_json() / post_json() consume the response internally and never decremented at all, so the counter only ever grew.

The counter is not cosmetic — it drives two live mechanisms:

  1. Pool auto-sizing — _adjust_pool_size() computes utilization = _active_requests / _current_pool_size. A monotonically rising numerator pins utilisation above the 0.7 grow threshold permanently, so the pool ratchets toward _pool_max (200) regardless of real concurrency, and the < 0.2 shrink branch becomes unreachable.
  2. Deferred pool recreation — _handle_pool_recreation() defers recreation while _active_requests > 0, and only decrement_active() reaching 0 applies it. With a counter that never returns to zero, a pending pool resize is never applied.

It also makes get_stats()["active_requests"] and utilization wrong for anything reading them.

The fix had to avoid adding more caller obligations — a contract that ~151 call sites already ignore will keep being ignored. So the decrement moved into the helper that owns the full lifecycle, where it cannot be forgotten, rather than being spread across call sites. The raw-response path (request() / get() / post()) stays caller-owned exactly as documented, because streaming callers legitimately need the slot held past the initial request (#680).

What Changed

Scope is autobot_shared/http_client.py + its test file. No call sites were converted.

  • New tracked_request() async context manager — issues the request, yields the response inside async with response, and decrements in a finally. It owns request + response body + counter as one unit. Named for symmetry with the existing tracked_session().
  • get_json() / post_json() now use it, so both balance the counter automatically. These were the two internal response-consuming helpers; get()/post() return raw responses and remain caller-owned.
  • Docstrings made explicit on request(), get(), and post(): the returned response is caller-owned on the success path and requires decrement_active(), with tracked_request() called out as the forget-proof alternative.
  • Fixed the in-file example_usage() streaming sample, which demonstrated the unbalanced pattern (async with await http_client.get(...) with no decrement) and was effectively teaching the bug.

No double-decrement is possible: if request() itself raises, it decrements on its own error path and the try in tracked_request() is never entered.

Verification

New tests fail on the old code and pass on the new — confirmed by temporarily restoring the old get_json() body:

FAILED test_get_json_returns_counter_to_baseline
FAILED test_raise_for_status_failure_returns_counter_to_baseline   assert 2 == 1
FAILED test_sequential_requests_do_not_accumulate                  assert 7 == 2
3 failed, 2 passed

assert 7 == 2 is the reported regression exactly: 5 sequential get_json() calls left the counter 5 higher than baseline.

After the fix:

$ python3 -m pytest autobot_shared/http_client_test.py -q
11 passed in 0.85s

Seven tests added, covering every case: baseline restored after a successful get_json(), after a successful post_json(), after a transport failure, after a raise_for_status() failure mid-block, and after 10 sequential requests (baseline, not baseline+10). Plus tracked_request() exception-safety.

Streaming contract asserted unchanged — test_streaming_contract_unchanged_single_decrement() verifies request() still hands back an un-decremented slot (baseline + 1 while held, so mid-stream pool recreation is still correctly deferred) and that one caller decrement_active() returns it to baseline.

Full suite, no regressions:

$ python3 -m pytest autobot_shared/ -q
1144 passed in 69.22s

# baseline, excluding the 7 new tests
1137 passed, 7 deselected in 49.28s

1137 baseline + 7 new = 1144. No pre-existing test changed state.

$ python3 -m flake8 autobot_shared/http_client.py autobot_shared/http_client_test.py
flake8 exit=0

The sole production decrement_active() caller (chat_workflow/manager.py:2147) is a streaming Ollama path built on raw post() — untouched by this change, and not at risk of double-decrementing.

Model Used

claude-opus-5

Closes #12981

@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No hardcoded values detected that have SSOT config equivalents!

@mrveiss
mrveiss merged commit 8f288aa into Dev_new_gui Jul 29, 2026
40 checks passed
@mrveiss
mrveiss deleted the issue-12981 branch July 29, 2026 21:46
mrveiss added a commit that referenced this pull request Jul 29, 2026
…12979) (#12994)

Converts the 25 remaining per-request aiohttp.ClientSession constructions in
integrations/cicd_integration.py (14) and
integrations/version_control_integration.py (11) onto the shared pooled
client, so Jenkins/GitLab CI/CircleCI/GitLab/Bitbucket calls reuse
connections instead of re-resolving DNS and re-establishing TLS per request.

Per-shape split (18 safe / 7 careful):
- 16 get_json() + 2 post_json() where the site already did
  raise_for_status() then read json().
- 7 tracked_request(): 5 test_connection() sites that inspect
  response.status and return a structured IntegrationHealth, plus Jenkins
  _get_build_log() (reads text()) and Jenkins _trigger_build() (POST that
  never reads a body -- Jenkins answers buildWithParameters with an empty
  201, so post_json() would raise ContentTypeError on the success path).

tracked_request() is used rather than 'async with await client.get(...)'
because the latter releases the connection but leaves _active_requests
permanently incremented (#12981/#12989).

The five test_connection() sites pass suppress_error_log=True: each already
logs the failure itself and encodes it as IntegrationStatus.UNHEALTHY, so
the manager's default ERROR log would only duplicate it.

Also extracts BitbucketIntegration._request_kwargs(), replacing the same
headers/timeout/optional-auth block repeated at five call sites.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
mrveiss added a commit that referenced this pull request Jul 29, 2026
…12992)

Both monitoring_integration.py test_connection sites used the pre-#12989
recipe (async with await client.get(...)). That releases the connection but
increments _active_requests without ever decrementing it, so the counter grew
1:1 with requests, skewing _adjust_pool_size() utilisation and making the
_active_requests == 0 deferred-recreation gate unreachable.

Converted both to tracked_request("GET", ...), which owns the counter as well
as the response block. The status-inspection logic is unchanged: these two
sites must return a structured IntegrationHealth rather than raise, so they
remain deliberate carve-outs from the *_json() helpers.

Also adds a pytest ratchet guarding the #12979 sweep: raw
aiohttp.ClientSession(...) constructions under autobot-backend/ are counted via
AST and asserted not to exceed a recorded ceiling of 112, so the existing
backlog stays green while newly introduced raw sessions fail the suite.
mrveiss added a commit that referenced this pull request Jul 29, 2026
…12992)

Both monitoring_integration.py test_connection sites used the pre-#12989
recipe (async with await client.get(...)). That releases the connection but
increments _active_requests without ever decrementing it, so the counter grew
1:1 with requests, skewing _adjust_pool_size() utilisation and making the
_active_requests == 0 deferred-recreation gate unreachable.

Converted both to tracked_request("GET", ...), which owns the counter as well
as the response block. The status-inspection logic is unchanged: these two
sites must return a structured IntegrationHealth rather than raise, so they
remain deliberate carve-outs from the *_json() helpers.

Also adds a pytest ratchet guarding the #12979 sweep: raw
aiohttp.ClientSession(...) constructions under autobot-backend/ are counted via
AST and asserted not to exceed a recorded ceiling of 112, so the existing
backlog stays green while newly introduced raw sessions fail the suite.
mrveiss added a commit that referenced this pull request Jul 30, 2026
…12992) (#12996)

Both monitoring_integration.py test_connection sites used the pre-#12989
recipe (async with await client.get(...)). That releases the connection but
increments _active_requests without ever decrementing it, so the counter grew
1:1 with requests, skewing _adjust_pool_size() utilisation and making the
_active_requests == 0 deferred-recreation gate unreachable.

Converted both to tracked_request("GET", ...), which owns the counter as well
as the response block. The status-inspection logic is unchanged: these two
sites must return a structured IntegrationHealth rather than raise, so they
remain deliberate carve-outs from the *_json() helpers.

Also adds a pytest ratchet guarding the #12979 sweep: raw
aiohttp.ClientSession(...) constructions under autobot-backend/ are counted via
AST and asserted not to exceed a recorded ceiling of 112, so the existing
backlog stays green while newly introduced raw sessions fail the suite.
mrveiss added a commit that referenced this pull request Jul 30, 2026
…ring+service_monitor+env_analyzer through the shared pool (#12979)

Batch 8 of the raw-aiohttp.ClientSession sweep: integrations/base.py,
github_integration.py, microsoft365_integration.py, notion_integration.py,
api/monitoring.py, api/service_monitor.py, code_analysis/src/env_analyzer.py
now route through autobot_shared.http_client.get_http_client()'s
tracked_request() instead of constructing per-request sessions. All 9 sites
use tracked_request() (none raise on HTTP status, so get_json()/post_json()
would change error-path contracts). api/marketplace_sources.py's
_fetch_catalog_document stays RAW: it pins an SSRF-guarded TCPConnector that
the pooled client cannot express per-request, the same carve-out category as
knowledge/connectors/oauth_flow.py.

Also fixed a pre-existing #12981-class counter leak found while touching
api/monitoring.py: _query_prometheus_instant/_query_prometheus_range already
used the shared client but via the pre-#12989 `async with await client.get()`
form, which releases the connection but never balances _active_requests.

Dropped env_analyzer.py's EnvironmentAnalyzer._call_ollama_filter session
parameter: its sole caller always passed session=None, so the
reused-session branch was dead code the pooled client now makes unnecessary.

Ceiling lowered 53 -> 30 in tests/test_raw_client_session_ceiling_12992.py,
re-measured by AST walker against a tree freshly synced to
origin/Dev_new_gui immediately before push.
mrveiss added a commit that referenced this pull request Jul 30, 2026
…ring+service_monitor+env_analyzer through the shared pool (#12979) (#13003)

Batch 8 of the raw-aiohttp.ClientSession sweep: integrations/base.py,
github_integration.py, microsoft365_integration.py, notion_integration.py,
api/monitoring.py, api/service_monitor.py, code_analysis/src/env_analyzer.py
now route through autobot_shared.http_client.get_http_client()'s
tracked_request() instead of constructing per-request sessions. All 9 sites
use tracked_request() (none raise on HTTP status, so get_json()/post_json()
would change error-path contracts). api/marketplace_sources.py's
_fetch_catalog_document stays RAW: it pins an SSRF-guarded TCPConnector that
the pooled client cannot express per-request, the same carve-out category as
knowledge/connectors/oauth_flow.py.

Also fixed a pre-existing #12981-class counter leak found while touching
api/monitoring.py: _query_prometheus_instant/_query_prometheus_range already
used the shared client but via the pre-#12989 `async with await client.get()`
form, which releases the connection but never balances _active_requests.

Dropped env_analyzer.py's EnvironmentAnalyzer._call_ollama_filter session
parameter: its sole caller always passed session=None, so the
reused-session branch was dead code the pooled client now makes unnecessary.

Ceiling lowered 53 -> 30 in tests/test_raw_client_session_ceiling_12992.py,
re-measured by AST walker against a tree freshly synced to
origin/Dev_new_gui immediately before push.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant