Skip to content

Commit 8e7dee2

Browse files
committed
test(adapters): cover endpoint interceptor and adapter internals
Raise core coverage past the 96% gate (93.29% -> 96%) with real unit tests for the previously-untested adapter paths: - endpoint interceptor: upstream-URL suffix stripping, None-content normalization, success path (body/header merge, api-key injection, hop-by-hop stripping), retry-on-status, timeout -> 504, ClientError raise/retry, and close(). - adapter internals: lazy context creation in a fresh contextvars.Context, AdapterResponse.ok, request_logging._trunc_preview branches, middleware request/response conversion (streaming/plain/non-json bodies, bytes body) and the endpoint guard, proxy host/endpoint guards + _bind_port/ _wait_for_health, and registry/pipeline error + best-effort paths. All upstream HTTP/SSH is mocked; tests are offline and deterministic. Signed-off-by: Michal Bien <mbien@nvidia.com>
1 parent 18cbdc9 commit 8e7dee2

2 files changed

Lines changed: 408 additions & 0 deletions

File tree

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""Tests for the ``endpoint`` request-to-response interceptor.
16+
17+
All upstream HTTP is faked by patching ``endpoint.global_request`` and
18+
``endpoint.asyncio.sleep`` so the tests are fast and offline.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import asyncio
24+
import json
25+
from unittest.mock import AsyncMock, patch
26+
27+
import aiohttp
28+
import pytest
29+
30+
from nemo_gym.adapters.interceptors.endpoint import Interceptor
31+
from nemo_gym.adapters.types import AdapterRequest, AdapterResponse, InterceptorContext
32+
33+
34+
_ENDPOINT = "nemo_gym.adapters.interceptors.endpoint"
35+
36+
37+
class _FakeResp:
38+
"""Minimal stand-in for the aiohttp response used as an async ctx manager."""
39+
40+
def __init__(self, status: int, headers: dict | None = None, body=b""):
41+
self.status = status
42+
self.headers = headers or {}
43+
self._body = body if isinstance(body, bytes) else json.dumps(body).encode()
44+
45+
async def __aenter__(self):
46+
return self
47+
48+
async def __aexit__(self, *exc):
49+
return False
50+
51+
async def read(self) -> bytes:
52+
return self._body
53+
54+
55+
def _req(body: dict | None = None, path: str = "/v1/chat/completions") -> AdapterRequest:
56+
return AdapterRequest(
57+
method="POST",
58+
path=path,
59+
headers={"Host": "drop-me", "Content-Length": "3", "X-Keep": "1"},
60+
body=body or {"model": "m"},
61+
ctx=InterceptorContext(),
62+
)
63+
64+
65+
def test_upstream_url_known_suffixes_stripped():
66+
for suffix in ("/chat/completions", "/completions", "/embeddings"):
67+
ic = Interceptor(upstream_url=f"https://api.example.com/v1{suffix}/")
68+
assert ic._upstream_url == "https://api.example.com/v1"
69+
# A non-API suffix is left intact.
70+
assert Interceptor(upstream_url="http://up/v1")._upstream_url == "http://up/v1"
71+
72+
73+
def test_normalize_content_replaces_none_only():
74+
body = {
75+
"choices": [
76+
{"message": {"content": None}},
77+
{"delta": {"content": None}},
78+
{"message": {"content": "keep"}},
79+
{"message": {}}, # no content key -> untouched
80+
]
81+
}
82+
Interceptor._normalize_content(body)
83+
assert body["choices"][0]["message"]["content"] == ""
84+
assert body["choices"][1]["delta"]["content"] == ""
85+
assert body["choices"][2]["message"]["content"] == "keep"
86+
assert "content" not in body["choices"][3]["message"]
87+
88+
89+
async def test_intercept_request_success_merges_body_and_headers():
90+
ic = Interceptor(upstream_url="http://up/v1", api_key="sekret", extra_body={"stream": False})
91+
mock = AsyncMock(
92+
return_value=_FakeResp(
93+
200,
94+
{"Content-Type": "application/json"},
95+
{"choices": [{"message": {"content": None}}]},
96+
)
97+
)
98+
with patch(f"{_ENDPOINT}.global_request", new=mock):
99+
resp = await ic.intercept_request(_req(body={"model": "m"}))
100+
101+
assert isinstance(resp, AdapterResponse) and resp.status_code == 200
102+
kwargs = mock.call_args.kwargs
103+
# extra_body merged into the forwarded body
104+
assert json.loads(kwargs["data"]) == {"model": "m", "stream": False}
105+
# api key injected, content-type defaulted, hop-by-hop headers stripped
106+
assert kwargs["headers"]["Authorization"] == "Bearer sekret"
107+
assert kwargs["headers"]["Content-Type"] == "application/json"
108+
assert "Host" not in kwargs["headers"] and "Content-Length" not in kwargs["headers"]
109+
assert kwargs["headers"]["X-Keep"] == "1"
110+
assert kwargs["url"] == "http://up/v1/v1/chat/completions"
111+
# None content normalized to ""
112+
assert resp.body["choices"][0]["message"]["content"] == ""
113+
# response headers preserved (original case) as latin-1 byte tuples
114+
assert (b"Content-Type", b"application/json") in resp.headers
115+
116+
117+
async def test_intercept_request_retries_on_status_then_succeeds():
118+
ic = Interceptor(upstream_url="http://up/v1", max_retries=2, retry_on_status=[503])
119+
seq = [_FakeResp(503, {"Retry-After": "0"}), _FakeResp(200, {}, {"ok": True})]
120+
with (
121+
patch(f"{_ENDPOINT}.global_request", new=AsyncMock(side_effect=seq)),
122+
patch(f"{_ENDPOINT}.asyncio.sleep", new=AsyncMock()) as sleep,
123+
):
124+
resp = await ic.intercept_request(_req())
125+
assert resp.status_code == 200
126+
assert resp.body == {"ok": True}
127+
sleep.assert_awaited() # Retry-After honored
128+
129+
130+
async def test_intercept_request_non_json_response_passed_through_as_bytes():
131+
ic = Interceptor(upstream_url="http://up/v1")
132+
with patch(
133+
f"{_ENDPOINT}.global_request",
134+
new=AsyncMock(return_value=_FakeResp(200, {"Content-Type": "text/plain"}, b"not-json")),
135+
):
136+
resp = await ic.intercept_request(_req())
137+
assert resp.body == b"not-json"
138+
139+
140+
async def test_intercept_request_timeout_returns_504():
141+
ic = Interceptor(upstream_url="http://up/v1", max_retries=0)
142+
with patch(f"{_ENDPOINT}.global_request", new=AsyncMock(side_effect=asyncio.TimeoutError())):
143+
resp = await ic.intercept_request(_req())
144+
assert resp.status_code == 504
145+
assert resp.body["error"]["type"] == "timeout"
146+
147+
148+
async def test_intercept_request_timeout_retries_then_504():
149+
ic = Interceptor(upstream_url="http://up/v1", max_retries=1)
150+
with (
151+
patch(f"{_ENDPOINT}.global_request", new=AsyncMock(side_effect=asyncio.TimeoutError())),
152+
patch(f"{_ENDPOINT}.asyncio.sleep", new=AsyncMock()) as sleep,
153+
):
154+
resp = await ic.intercept_request(_req())
155+
assert resp.status_code == 504
156+
sleep.assert_awaited()
157+
158+
159+
async def test_intercept_request_client_error_raises_without_retries():
160+
ic = Interceptor(upstream_url="http://up/v1", max_retries=0)
161+
with patch(f"{_ENDPOINT}.global_request", new=AsyncMock(side_effect=aiohttp.ClientError("boom"))):
162+
with pytest.raises(aiohttp.ClientError):
163+
await ic.intercept_request(_req())
164+
165+
166+
async def test_intercept_request_client_error_retries_then_raises():
167+
ic = Interceptor(upstream_url="http://up/v1", max_retries=1)
168+
with (
169+
patch(f"{_ENDPOINT}.global_request", new=AsyncMock(side_effect=aiohttp.ClientError("boom"))),
170+
patch(f"{_ENDPOINT}.asyncio.sleep", new=AsyncMock()) as sleep,
171+
):
172+
with pytest.raises(aiohttp.ClientError):
173+
await ic.intercept_request(_req())
174+
sleep.assert_awaited()
175+
176+
177+
async def test_close_is_noop():
178+
ic = Interceptor(upstream_url="http://up/v1")
179+
assert await ic.close() is None

0 commit comments

Comments
 (0)