Skip to content

fix(proxy): resolve upstream credentials off the event loop - #85017

Open
briandevans wants to merge 6 commits into
NousResearch:mainfrom
briandevans:fix/proxy-credentials-off-event-loop
Open

fix(proxy): resolve upstream credentials off the event loop#85017
briandevans wants to merge 6 commits into
NousResearch:mainfrom
briandevans:fix/proxy-credentials-off-event-loop

Conversation

@briandevans

@briandevans briandevans commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Symptom: with hermes proxy running, an agent's streaming completion freezes
mid-token for several seconds — and every other concurrent stream through the
same proxy freezes at the same moment, then everything resumes together. A
/health poll from a supervisor or container healthcheck can trigger it on its
own.

Cause: create_app registers exactly two async def handlers, and between
them they called all three of the adapter contract's synchronous, hard-blocking
methods directly on the aiohttp event loop.

UpstreamAdapter is an entirely synchronous contract —
hermes_cli/proxy/adapters/base.py, every method a plain def — and the shipped
adapters implement it with blocking file and network I/O:

handler call what is underneath it
handle_proxy adapter.get_credential() (server.py:125) nous_portal.py:76_read_state()_auth_store_lock()_load_auth_store()resolve_nous_runtime_credentials(), a token-refresh POST. On a terminal AuthError, _save_state() takes _auth_store_lock() a second time and rewrites the store. xai.py:56_load_pool(), a disk read.
handle_health adapter.is_authenticated() (server.py:106) nous_portal.py:65_read_state()the same _auth_store_lock(). xai.py:52_load_pool().
handle_proxy adapter.get_retry_credential() (server.py:202) nous_portal.py:79_get_credential(force_refresh=True) → the same _auth_store_lock(), and the refresh POST above is now unconditional. xai.py:76_load_pool() + try_refresh_current() / mark_exhausted_and_rotate() under self._lock.

_auth_store_lock is a cross-process advisory lock with
AUTH_LOCK_TIMEOUT_SECONDS = 15.0 (hermes_cli/auth.py:110, taken at :1225),
so any concurrent hermes auth … command in a different process can hold it
while the proxy's event loop sits inside it.

Why this is severe rather than cosmetic. handle_proxy forwards with
aiohttp.ClientTimeout(total=None, sock_connect=15, sock_read=300)
(server.py:136) — long-lived streaming completions are the normal case, not
the exception. The proxy is one process with one loop, so blocking inside
credential resolution stops every in-flight stream, not just the request that
triggered it. Blast radius is every proxied request of every hermes proxy
user; this is the subsystem's hot path, not a rare state.

For /health the repo already states the invariant against itself —
adapters/base.py:63-68:

def is_authenticated(self) -> bool:"Should be cheap — no network calls.
Used by proxy start for a clear up-front error before binding a port."

That holds for the proxy start preflight, which runs in a plain synchronous
CLI function (proxy/cli.py:44). It does not hold on the event loop, where a
15-second cross-process file lock is not cheap — and /health is exactly what a
supervisor, systemd unit, container healthcheck or load balancer polls on a
fixed interval.

Fix: dispatch all three through await asyncio.to_thread(...). asyncio is
already imported at server.py:14; requires-python = ">=3.11,<3.14"
(pyproject.toml:15), and to_thread is the house idiom in this package (102
matching lines vs 23 for run_in_executor across hermes_cli/). It is also the idiom of
the maintainer-side sweep this completes for the proxy —
965a5487884 "serialize config mutations and finish the router off-loop
sweep"
.

This is a pure scheduling change:

  • Error contract unchanged. to_thread re-raises the worker's exception in
    the awaiting frame, so handle_proxy's existing except Exception401 upstream_auth_failed mapping still fires identically, and the retry branch's
    except Exceptionretry_cred = None still swallows a failed rotation and
    streams the upstream's own rejection back. Both pinned by a test.
  • Thread-safety unchanged. Both adapters guard their mutable state with
    self._lock (nous_portal.py:51; xai mutates self._pool under the same
    lock), so concurrent offloaded calls serialise exactly as they do today. The
    offload moves the wait off the loop; it does not remove the mutual
    exclusion.
  • Response bodies unchanged. /health returns the same JSON.

Why at the handler and not in the ABC. Making UpstreamAdapter async would
be the wrong layer and would collide with in-flight work: four open PRs are
currently adding brand-new upstream adapters that each implement def get_credential / def is_authenticated synchronously (#58647 anthropic.py,
#54877 codex.py, #62510 and #62297 openai_codex.py). Offloading once in
server.py fixes every adapter — including those four — while leaving the ABC
they subclass completely untouched.

Related Issue

No filed issue. The invariant is stated in-repo at
hermes_cli/proxy/adapters/base.py:65-68 ("Should be cheap — no network
calls"), and 965a5487884 is the existing off-loop sweep this extends to the
proxy.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

Six independently cherry-pickable commits, one fix/test pair per site:

  1. fix(proxy): resolve upstream credentials off the event loop
    hermes_cli/proxy/server.py:125, handle_proxy.
  2. test(proxy): cover credential resolution blocking the proxy event loop
    new file tests/hermes_cli/test_proxy_off_loop.py.
  3. fix(proxy): keep the health endpoint off the auth-store lock
    hermes_cli/proxy/server.py:106, handle_health.
  4. test(proxy): cover health responsiveness while the auth store is locked.
  5. fix(proxy): resolve the 401/429 retry credential off the event loop
    hermes_cli/proxy/server.py:202, handle_proxy's rotation branch.
  6. test(proxy): cover the retry credential blocking the proxy event loop.

Net diff: hermes_cli/proxy/server.py +26/-3, plus one new test file.

Site coverage (the full sweep of this root cause)

grep for adapter calls outside hermes_cli/proxy/adapters/ returns exactly
three blocking sites, all in server.py. adapter.display_name and
adapter.allowed_paths are constant properties on both adapters and are not
affected. proxy/cli.py also calls is_authenticated/get_credential, but
from plain synchronous CLI functions with no event loop, so it does not share
the root cause.

site line shipped
handle_proxyget_credential() :125
handle_healthis_authenticated() :106
handle_proxyget_retry_credential() :202

All three blocking methods on the contract are now covered. This PR previously
shipped two of the three; that has been corrected — see the next section.

get_retry_credential (server.py:202) — added, with a known textual overlap

An earlier revision of this PR left this site alone because #62297 rewrites
that exact block, and noted it should be handled by whichever of the two lands
second. On reflection that was the wrong call, for three reasons:

  1. Shipping two of three is the worse outcome. The whole premise of this PR
    is that the proxy's single loop must never sit inside adapter I/O. A partial
    fix leaves the loop frozen on exactly the path where the harm is largest —
    see below — while making the file look swept.
  2. This is the most expensive of the three, not the cheapest.
    NousPortalAdapter.get_retry_credential (nous_portal.py:79) routes into
    _get_credential(force_refresh=True), so the token-refresh POST that
    get_credential performs only near expiry is unconditional here — under
    the same 15s cross-process _auth_store_lock().
    XAIGrokAdapter.get_retry_credential (xai.py:76) loads the key pool off
    disk and calls try_refresh_current / mark_exhausted_and_rotate under
    self._lock. And a 429 is by definition the moment the proxy is busiest, so
    it is the worst possible time to stall every other in-flight stream.
  3. The overlap is textual, not semantic. feat(proxy): add OpenAI Codex OAuth pool upstream #62297's hunk
    @@ -198,23 +270,57 @@ (old lines 198–220, read from the diff itself)
    adds an error_context= argument, converts if upstream_resp.status in {401, 429} into a while, and adds fail_closed_on_exhaustion. None of
    that conflicts with where the call runs: whichever lands second, the
    reconciliation is wrapping the same call in await asyncio.to_thread(...)
    inside the new loop structure. That PR has had no commit since 2026-07-11.

I have deliberately not touched the signature, the if/while shape, or the
exhaustion handling, so the hunk stays as small and as cleanly re-appliable as
possible on top of either ordering.

Related / positioning

Eleven open PRs touch hermes_cli/proxy/server.py. I read each diff and took the
hunk anchors from the diffs themselves, not from PR titles:

PR server.py hunks (old lines) overlaps :106 / :125?
#62297 12–24, 32–52, 85–91, 93–98, 100–114, 143–153, 198–220, 224–229, 249–254, 260–266, 268–274, 297–300 yes — see below
#84890 7–17, 29–34, 133–138, 215–220 no
#54877 12–19, 81–86, 129–149, 212–217 no
#62510 12–17, 85–91, 109–114, 144–149, 249–254, 260–266 no
#58647 45–50, 130–135, 141–146 no
#36130 5–12, 130–135 no
#64554 223–238 no
#29279 225–249 no
#28077 52–57, 82–87, 90–96 no
#73640 29–37, 45–50 no
#4691 different file — root-level proxy/server.py, a separate credential-broker daemon n/a

#62297 in detail, since it is the only overlap. Its 198–220 hunk is the one
discussed above. Its 100–114 hunk does touch handle_health, but only
to restyle the json_response(...) call from the expanded form to braces — it
keeps adapter.is_authenticated() synchronous and on the loop, so it is a
formatting collision, not a competing fix. I have kept the /health change
here because it is the substantive one and because the invariant it restores is
the repo's own; rebasing either PR over the other in that block is a one-line
reconciliation.

I also grepped every one of those eleven diffs' added lines for all four
offload idioms (to_thread, run_in_executor, run_in_threadpool,
ThreadPoolExecutor) and for async def get_credential / async def is_authenticated. Zero hits in all eleven — none of them offloads these
calls, and none makes the adapter methods async. (The single corpus-wide hit,
run_in_executor in #4691, is in the unrelated root-level proxy/ daemon.) So
this change is strictly additive to everyone else's work in this file.

The new tests live in tests/hermes_cli/test_proxy_off_loop.py rather than
tests/hermes_cli/test_proxy.py for the same reason — six of those open PRs
touch the existing file.

How to Test

uv run --with pytest --with pytest-asyncio python3 -m pytest \
  tests/hermes_cli/test_proxy_off_loop.py tests/hermes_cli/test_proxy.py -q

Before/after, verified in both directions. With the three production hunks
reverted and the tests kept, on origin/main:

FAILED tests/hermes_cli/test_proxy_off_loop.py::test_get_credential_runs_off_the_event_loop
E   AssertionError: get_credential ran on the event-loop thread (8272468160);
    it blocks on a cross-process auth-store lock and must be offloaded
E   assert 8272468160 != 8272468160

FAILED tests/hermes_cli/test_proxy_off_loop.py::test_event_loop_keeps_running_while_credentials_resolve
E   AssertionError: only 0 loop iterations ran during a 0.5s credential
    resolution — the event loop was frozen
E   assert 0 >= 3

FAILED tests/hermes_cli/test_proxy_off_loop.py::test_is_authenticated_runs_off_the_event_loop
E   AssertionError: is_authenticated ran on the event-loop thread (8272468160);
    it takes the cross-process auth-store lock and must be offloaded

FAILED tests/hermes_cli/test_proxy_off_loop.py::test_event_loop_keeps_running_while_health_resolves_auth_state
E   AssertionError: only 0 loop iterations ran during a 0.5s /health auth
    check — the event loop was frozen

FAILED tests/hermes_cli/test_proxy_off_loop.py::test_get_retry_credential_runs_off_the_event_loop
E   AssertionError: get_retry_credential ran on the event-loop thread
    (8272468160); it force-refreshes the upstream token under a cross-process
    auth-store lock and must be offloaded

FAILED tests/hermes_cli/test_proxy_off_loop.py::test_event_loop_keeps_running_while_the_retry_credential_resolves
E   AssertionError: only 0 loop iterations ran during a 0.5s 429 credential
    rotation — the event loop was frozen
E   assert 0 >= 3

With the fix: 12 passed across both proxy test files, and 32 passed across the
adjacent proxy-touching suites (tests/test_iron_proxy_cli.py,
tests/hermes_cli/test_nous_inference_url_validation.py,
tests/cli/test_cli_status_command.py, tests/gateway/test_unknown_command.py).
Every one of the six commits is green on its own, checked out individually.

A note on how these tests are written

The primary assertion in each pair is thread identity, not latency, and that
is deliberate. A latency assertion measured with an HTTP client running on the
blocked loop is vacuous: the client's own timer cannot advance until the block
ends, so it reports a millisecond response time on code that was provably frozen
for a second. The secondary assertion avoids the same trap by reading a
heartbeat counter that ticks on the loop, sampled by the adapter itself on
entry and exit — 0 iterations before the fix, ~50 after.

The harness mirrors the one already in tests/hermes_cli/test_proxy.py: the
proxy and a fake upstream run as real aiohttp servers on ephemeral ports under a
single asyncio.run, guarded by pytest.importorskip("aiohttp"). One real
server means exactly one loop, which is what makes the starvation observation
meaningful. No new test dependency — pytest-aiohttp is deliberately avoided,
as the existing file's own comment explains.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 15 (arm64), Python 3.11

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guideasyncio.to_thread is platform-neutral; the tests use ephemeral loopback ports and no platform-specific primitives
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

`create_app` registers `handle_proxy` as an `async def`, and it called
`adapter.get_credential()` directly on the aiohttp event loop.

`UpstreamAdapter` is a synchronous contract (`adapters/base.py` — every
method is a plain `def`), and the shipped adapters implement it with
blocking I/O. `NousPortalAdapter.get_credential` takes
`_auth_store_lock()` — a cross-process advisory lock with
`AUTH_LOCK_TIMEOUT_SECONDS = 15.0` (`hermes_cli/auth.py:110`) — reads
`auth.json` off disk, and may issue a token-refresh POST; on a terminal
`AuthError` it takes that lock a second time to persist the quarantined
state. `XAIGrokAdapter.get_credential` reads its key pool off disk.

The proxy is one process with one loop, and `handle_proxy` streams with
`sock_read=300`, so long-lived completions are the normal case. Blocking
inside credential resolution therefore freezes *every* concurrent
in-flight stream mid-token for the duration — a concurrent `hermes auth`
command holding the auth-store lock is enough to do it. Every proxied
request goes through this path.

Dispatch through `asyncio.to_thread` instead. This is a pure scheduling
change: `to_thread` re-raises the worker's exception in the awaiting
frame, so the existing `except Exception` -> 401 `upstream_auth_failed`
mapping is unchanged, and the adapters' own `self._lock` still serialises
concurrent resolutions exactly as before. Fixing it at the handler also
leaves the synchronous `UpstreamAdapter` ABC untouched, so it covers
every adapter without conflicting with in-flight work that subclasses it.
Adds `tests/hermes_cli/test_proxy_off_loop.py`, mirroring the harness in
`test_proxy.py`: the proxy and a fake upstream run as real aiohttp
servers on ephemeral ports under a single `asyncio.run`, guarded by
`pytest.importorskip("aiohttp")` — no pytest-aiohttp dependency.

The primary assertion is thread identity, not latency. A latency
assertion measured with an HTTP client on the blocked loop is vacuous:
the client's own timer cannot advance until the block ends, so it reports
a fast response on code that was provably frozen.

- `test_get_credential_runs_off_the_event_loop` records
  `threading.get_ident()` inside the adapter and compares it to the loop
  thread. Before the fix both are the same ident.
- `test_event_loop_keeps_running_while_credentials_resolve` runs a
  heartbeat task on the loop and has the adapter sample its counter on
  entry and exit, so the reading is taken from the loop rather than
  through a client that shares it. Before the fix exactly 0 iterations
  run across a 0.5s stall; after it, ~50.
- `test_credential_failure_still_maps_to_401` pins the error contract
  across the change of call form. It is deliberately not in the
  red-before set — it guards behaviour the fix must leave alone.
`handle_health` called `adapter.is_authenticated()` inline from an
`async def`. `UpstreamAdapter.is_authenticated` is documented as
"Should be cheap — no network calls. Used by `proxy start` for a clear
up-front error before binding a port." (`adapters/base.py`), and that is
true of the `proxy start` preflight, which runs in a plain synchronous
CLI function. It is not true on the event loop:
`NousPortalAdapter.is_authenticated` goes through `_read_state()`, which
takes `_auth_store_lock()` — the same cross-process lock with a 15s
timeout as credential resolution — and `XAIGrokAdapter` reads its key
pool off disk.

`/health` is precisely what a supervisor, systemd unit, container
healthcheck or load balancer polls, on a fixed interval, so it is the
endpoint least able to afford a lock wait; and a wait here freezes every
concurrent proxied stream, not just the healthcheck.

Offload it with `asyncio.to_thread`. The response body is byte-identical;
only the scheduling changes.
Extends `test_proxy_off_loop.py` with the `/health` half, using the same
two-assertion shape as the credential tests:

- `test_is_authenticated_runs_off_the_event_loop` compares the thread the
  adapter's `is_authenticated` ran on against the loop thread. Before the
  fix they are the same ident.
- `test_event_loop_keeps_running_while_health_resolves_auth_state` reads a
  loop-side heartbeat counter sampled by the adapter across its own stall.
  Before the fix exactly 0 iterations run across 0.5s.

Both also assert the response is unchanged (`200`, `authenticated: true`),
so the offload cannot quietly alter what `/health` reports.
Copilot AI lite review requested due to automatic review settings August 13, 2026 03:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes hermes proxy event-loop starvation by offloading synchronous upstream adapter operations (is_authenticated() and get_credential()) onto worker threads, preventing streaming completions and /health checks from freezing all concurrent proxy traffic when adapters perform blocking disk/network I/O.

Changes:

  • Offload /health authentication checks via asyncio.to_thread(...) to keep health polling from blocking the aiohttp loop.
  • Offload upstream credential resolution in the proxy forwarding path via asyncio.to_thread(...) to prevent credential refresh / auth-store lock waits from freezing streaming.
  • Add a new regression test suite that asserts adapter calls run off the loop thread and that the loop continues to make progress during simulated blocking.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
hermes_cli/proxy/server.py Moves synchronous adapter calls (is_authenticated, get_credential) off the aiohttp event loop using asyncio.to_thread.
tests/hermes_cli/test_proxy_off_loop.py Adds tests that verify thread identity (off-loop execution) and heartbeat-based loop liveness during blocking adapter work.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +134 to 138
# it on a worker thread so a refresh or a contended lock cannot freeze
# every other in-flight streaming completion on this single loop.
try:
cred = adapter.get_credential()
cred = await asyncio.to_thread(adapter.get_credential)
except Exception as exc:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shipped. get_retry_credential is now offloaded too, so all three blocking methods on the UpstreamAdapter contract run off the loop.

fix(proxy): resolve the 401/429 retry credential off the event loop (e02b447900d), current head 5d0e3a16249eed3fae6d8db66afd88ef68fe15a1hermes_cli/proxy/server.py, the upstream_resp.status in {401, 429} branch:

retry_cred = await asyncio.to_thread(
    adapter.get_retry_credential,
    failed_credential=cred,
    status_code=upstream_resp.status,
)

Worth noting this was the most expensive of the three rather than the cheapest. NousPortalAdapter.get_retry_credential routes into _get_credential(force_refresh=True), so the token-refresh POST that get_credential only performs near expiry is unconditional here — under the same 15s cross-process _auth_store_lock(). XAIGrokAdapter.get_retry_credential loads the key pool off disk and rotates it under self._lock. And a 429 is by definition the moment the proxy is busiest, so it was the worst point at which to freeze every other in-flight stream.

The error contract is unchanged: to_thread re-raises in the awaiting frame, so the branch's existing except Exception -> retry_cred = None still swallows a failed rotation and streams the upstream's own rejection back.

Pinned by three tests in tests/hermes_cli/test_proxy_off_loop.py (naming them since a SHA does not survive a rebase):

  • test_get_retry_credential_runs_off_the_event_loop — thread identity, not latency. A latency assertion measured by an HTTP client on the blocked loop is vacuous, since the client's own timer cannot advance until the block ends.
  • test_event_loop_keeps_running_while_the_retry_credential_resolves — loop-side heartbeat sampled from inside the stalled adapter; records exactly 0 iterations across a 0.5s rotation before the fix.
  • test_retry_credential_failure_still_returns_the_upstream_rejection — guards the error contract above.

12 passed across test_proxy_off_loop.py + test_proxy.py. The PR body's site-coverage table and the note on the textual overlap with #62297's @@ -198,23 +270,57 @@ hunk have been updated accordingly.

@briandevans

Copy link
Copy Markdown
Contributor Author

CI note — the single red is a third-party toolchain download, not this change.

All required checks pass is green. All 12 Python tests / Run tests slice N/12 jobs and Python tests / e2e are green. The one FAILURE is the non-required build (amd64, ubuntu-latest, linux/amd64, …) job, which dies in Set up Python 3.11 (for docker tests) before any Python or docker work begins:

##[warning]attempt 2 failed; retrying in 10s: uv python install 3.11
error: Failed to install cpython-3.11.14-linux-x86_64-gnu
  Caused by: Failed to download https://github.com/astral-sh/python-build-standalone/releases/download/20260127/cpython-3.11.14%2B20260127-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz
  Caused by: http2 error
  Caused by: stream error received: refused stream before processing any application logic
  Caused by: Request failed after 3 retries
##[error]failed after 3 attempts: uv python install 3.11

That is a pinned astral-sh/python-build-standalone asset this PR does not touch, failing after uv's own three retries. The same download failed the same way on an unrelated PR of mine a few hours earlier, so it is currently flaking repo-wide rather than being specific to this branch.

`handle_proxy` already offloads the two credential reads on the happy path,
but the rotation inside the `upstream_resp.status in {401, 429}` branch still
called `adapter.get_retry_credential` inline on the event loop.

That is the most expensive of the three blocking methods on the
`UpstreamAdapter` contract, not the cheapest:

  * `NousPortalAdapter.get_retry_credential` routes into
    `_get_credential(force_refresh=True)`, so the token-refresh POST that
    `get_credential` performs only near expiry is unconditional here — and it
    runs under the same `_auth_store_lock()`, a cross-process advisory lock
    with a 15s timeout.
  * `XAIGrokAdapter.get_retry_credential` loads the key pool off disk and
    calls `try_refresh_current` / `mark_exhausted_and_rotate` under its lock.

So every upstream 401 or 429 froze the proxy's single event loop — and with
it every other in-flight streaming completion — for the whole rotation. A 429
is exactly when the proxy is busiest, which is the worst moment to stall.

Wrap it in `asyncio.to_thread`, matching the two sites above. The error
contract is unchanged: `to_thread` re-raises the worker's exception in the
awaiting frame, so the existing `except Exception -> retry_cred = None` still
swallows a failed rotation and streams the upstream's own rejection back.
Extends the off-loop suite to the third and last blocking method on the
`UpstreamAdapter` contract, the 401/429 rotation.

As with the two existing pairs, the primary assertion is **thread identity**,
not latency: a latency assertion measured by an HTTP client on the blocked
loop is vacuous, because the client's own timer cannot advance until the
block ends and it therefore reports a fast response on provably frozen code.

  * `test_get_retry_credential_runs_off_the_event_loop` records
    `threading.get_ident()` inside the fake adapter and compares it to the
    loop thread, and checks the rotation still works end to end (rejected
    bearer forwarded first, rotated bearer second).
  * `test_event_loop_keeps_running_while_the_retry_credential_resolves`
    samples a loop-side heartbeat counter from inside the stalled adapter. On
    the unfixed handler it records exactly 0 loop iterations across a 0.5s
    rotation.
  * `test_retry_credential_failure_still_returns_the_upstream_rejection`
    guards the error contract the change must leave alone: a raising rotation
    is still swallowed and the upstream's own 401 is streamed back, with no
    second forward.

A new `_build_rejecting_upstream` harness drives the `status in {401, 429}`
branch by rejecting every bearer except the rotated one.
@alt-glitch alt-glitch added type/perf Performance improvement or optimization P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard area/auth Authentication, OAuth, credential pools sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Aug 13, 2026
@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference, author can ignore or act on any point.

fix(proxy): resolve upstream credentials off the event loop

  1. hermes_cli/proxy/server.py — all three blocking calls now go through asyncio.to_thread, which uses the loop's default executor (bounded to min(32, cpu+4)). The 401/429 retry path is exactly when the proxy is busiest: many concurrent streaming completions can hit it simultaneously, and each spawns a worker for get_retry_credential (which can hold the 15s cross-process lock). Worth adding a small dedicated executor with a cap (and a per-call timeout) so credential resolution can queue instead of starving other to_thread users, or at least documenting the saturation behavior.

  2. hermes_cli/proxy/server.py::handle_health — moving is_authenticated off the loop is correct, but the endpoint is polled by supervisors/load balancers, so a contended auth lock now parks an executor thread for up to 15s per healthcheck. Consider a short asyncio.wait_for/timeout on the health probe so /health degrades to a bounded response (e.g. "authenticated": null + non-200) instead of hanging behind the lock.

  3. tests/hermes_cli/test_proxy_off_loop.py:221site._server.sockets reaches into aiohttp's private _server attribute; if aiohttp renames/removes it, the whole harness breaks at setup. Suggest adding a small comment noting the pinned aiohttp version or extracting the port via a less private route (site._server is the only way today, but flagging for awareness).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/perf Performance improvement or optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants