From 4f80b16649e0657b94dc35481bfafb6f6a96aeea Mon Sep 17 00:00:00 2001 From: Mehak Bindra Date: Fri, 16 Oct 2026 15:14:09 -0700 Subject: [PATCH] test: make HTTP mocks independent of environment proxy routing (#637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Cherry-pick 24ccd418b31d2c099f369e2a243f8610ee2ac6cb onto main with `-x` provenance, with the CONTRIBUTING.md addition subsequently removed. This PR contains only the test fix in five files; it does not include release-only ancestry, the 2.1.1 version bump, dependency changes, or production proxy/TLS changes. Construct mocked `httpx.AsyncClient` instances with explicit `MockTransport` before injecting them into SDK `Client` via `_http`, rather than replacing `_transport` after HTTPX has configured proxy mounts. Preserve clone settings, event hooks, and `share_http` behavior. Add proxy-environment regression coverage. ## Context ADO publish run 20467 failed 117 tests because real HTTP requests bypassed mocks. HTTPX 0.28.1/HTTP Core 1.0.9 and the affected test setup/YAML were unchanged from successful run 17012. The image and 1ESNetworkIsolation task changed (29.0.0 / 1.0.175 to 41.0.0 / 1.0.185), alongside Python and AnyIO versions; the exact trigger of proxy routing remains unproven. **HTTPX confirms the failure mechanism.** Its [proxy-discovery code](https://github.com/encode/httpx/blob/0.28.1/httpx/_utils.py) reads proxy settings through Python’s `getproxies()`, including the **Windows registry**, not just environment variables. Its [client code](https://github.com/encode/httpx/blob/0.28.1/httpx/_client.py) checks proxy mounts before the default transport—so replacing `_transport` afterward can leave proxies active. User-directed sequence: main first, then backport the merged commit to release/v2.1. This supersedes the proposed release-first fix in #636; closing that PR and handling the later backport are left to the coordinating session. Nothing is being merged or published here. ## Validation - 447 API/common tests passed with `HTTP_PROXY`, `HTTPS_PROXY`, and `ALL_PROXY` set to `http://127.0.0.1:1` and `NO_PROXY` empty. Imports were confirmed to resolve to this worktree. The wheel-build test was excluded only from this local forced-proxy run, not changed or skipped in CI. - Repository-wide Ruff lint and format checks passed; commit hooks passed (Pyright hook had no production files to check). - The wheel typing-marker test was run separately without forced proxy overrides and was blocked downloading build dependencies from PyPI by TLS `HandshakeFailure`. - Full Pyright could not be validated with the incomplete local environment. Dependency restoration failed with the same PyPI TLS handshake errors, including with native certificate trust. CI is required for complete build/type validation; no TLS verification or corporate proxy settings were disabled. --------- Copilot-Session: 75921e14-a409-4a9a-bb03-08659dc023de Copilot-Session: 70488242-a4f5-4f62-86f7-91d9335545c2 (cherry picked from commit 45694bdf374a10be5e023ede684d8a6cda0c9e63) --- packages/api/tests/conftest.py | 21 ++++- packages/api/tests/unit/test_base_client.py | 6 +- .../tests/unit/test_conversation_client.py | 18 ++-- packages/api/tests/unit/test_fixtures.py | 35 +++++++ packages/common/tests/test_client.py | 93 +++++++++++-------- 5 files changed, 123 insertions(+), 50 deletions(-) diff --git a/packages/api/tests/conftest.py b/packages/api/tests/conftest.py index 775b9c45e..bbf45fa10 100644 --- a/packages/api/tests/conftest.py +++ b/packages/api/tests/conftest.py @@ -235,15 +235,24 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.fixture def mock_http_client(mock_transport): """Create a mock HTTP client with transport.""" - client = Client(ClientOptions(base_url="https://mock.api.com")) - client.http._transport = mock_transport + client = Client( + ClientOptions(base_url="https://mock.api.com"), + _http=httpx.AsyncClient(transport=mock_transport, base_url="https://mock.api.com"), + ) return client def _preserve_test_transport_on_clone(client: Client, transport: httpx.MockTransport) -> Client: def clone(overrides: Optional[ClientOptions] = None, *, share_http: bool = False) -> Client: cloned = Client.clone(client, overrides, share_http=share_http) - cloned.http._transport = transport + if not share_http: + cloned.http = httpx.AsyncClient( + transport=transport, + base_url=cloned.http.base_url, + headers=cloned.http.headers, + timeout=cloned.http.timeout, + event_hooks=cloned.http.event_hooks, + ) return _preserve_test_transport_on_clone(cloned, transport) client.clone = clone # type: ignore[method-assign] @@ -366,8 +375,10 @@ def clear(self): capture = RequestCapture() transport = httpx.MockTransport(capture.handler) - client = Client(ClientOptions(base_url="https://mock.api.com")) - client.http._transport = transport + client = Client( + ClientOptions(base_url="https://mock.api.com"), + _http=httpx.AsyncClient(transport=transport, base_url="https://mock.api.com"), + ) _preserve_test_transport_on_clone(client, transport) client._capture = capture # type: ignore[attr-defined] # Attach for test access return client diff --git a/packages/api/tests/unit/test_base_client.py b/packages/api/tests/unit/test_base_client.py index cae61f284..5cc5b32e3 100644 --- a/packages/api/tests/unit/test_base_client.py +++ b/packages/api/tests/unit/test_base_client.py @@ -104,8 +104,10 @@ async def post_resource( def create_client(*, default_token: Token | None = None) -> tuple[Client, RequestRecorder]: recorder = RequestRecorder() - client = Client(ClientOptions(base_url="https://mock.api.com", token=default_token)) - client.http._transport = httpx.MockTransport(recorder.handler) + client = Client( + ClientOptions(base_url="https://mock.api.com", token=default_token), + _http=httpx.AsyncClient(transport=httpx.MockTransport(recorder.handler), base_url="https://mock.api.com"), + ) return client, recorder diff --git a/packages/api/tests/unit/test_conversation_client.py b/packages/api/tests/unit/test_conversation_client.py index ca2fc3e58..90d701b61 100644 --- a/packages/api/tests/unit/test_conversation_client.py +++ b/packages/api/tests/unit/test_conversation_client.py @@ -682,12 +682,14 @@ async def test_api_client_middleware_uses_supplied_attributes_without_deriving_s async def test_api_client_middleware_invokes_response_hook(self): tracer = RecordingTracer() hook_responses: list[httpx.Response] = [] - http_client = Client(ClientOptions(base_url="https://test.service.url")) def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"id": "response-id"}, headers={"content-type": "application/json"}) - http_client.http._transport = httpx.MockTransport(handler) + http_client = Client( + ClientOptions(base_url="https://test.service.url"), + _http=httpx.AsyncClient(transport=httpx.MockTransport(handler), base_url="https://test.service.url"), + ) api_client = ApiClient("https://test.service.url", http_client) async def on_response(span: Span, response: httpx.Response) -> None: @@ -714,13 +716,15 @@ async def on_response(span: Span, response: httpx.Response) -> None: async def test_api_client_middleware_swallows_response_hook_failure(self): tracer = RecordingTracer() - http_client = Client(ClientOptions(base_url="https://test.service.url")) response = httpx.Response(200, json={"id": "response-id"}, headers={"content-type": "application/json"}) def handler(request: httpx.Request) -> httpx.Response: return response - http_client.http._transport = httpx.MockTransport(handler) + http_client = Client( + ClientOptions(base_url="https://test.service.url"), + _http=httpx.AsyncClient(transport=httpx.MockTransport(handler), base_url="https://test.service.url"), + ) api_client = ApiClient("https://test.service.url", http_client) async def on_response(span: Span, response: httpx.Response) -> None: @@ -855,8 +859,10 @@ async def test_activity_create_records_diagnostics_on_error(self, mock_http_clie def handler(request: httpx.Request) -> httpx.Response: raise error - http_client = Client(ClientOptions(base_url="https://mock.api.com")) - http_client.http._transport = httpx.MockTransport(handler) + http_client = Client( + ClientOptions(base_url="https://mock.api.com"), + _http=httpx.AsyncClient(transport=httpx.MockTransport(handler), base_url="https://mock.api.com"), + ) client = ConversationClient("https://test.service.url", http_client) with ( diff --git a/packages/api/tests/unit/test_fixtures.py b/packages/api/tests/unit/test_fixtures.py index 56927ea8c..17d777122 100644 --- a/packages/api/tests/unit/test_fixtures.py +++ b/packages/api/tests/unit/test_fixtures.py @@ -10,6 +10,7 @@ import pytest from microsoft_teams.api.activities.invoke.sign_in import SignInFailureInvokeActivity from microsoft_teams.api.activities.invoke.suggested_action_submit import SuggestedActionSubmitInvokeActivity +from microsoft_teams.common.http import ClientOptions from pydantic import BaseModel # Map fixture filenames to their expected activity types @@ -19,6 +20,40 @@ } +@pytest.mark.parametrize("fixture_name", ["mock_http_client", "request_capture"]) +async def test_mock_http_clients_ignore_environment_proxy( + fixture_name: str, request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:1") + monkeypatch.setenv("ALL_PROXY", "http://127.0.0.1:1") + monkeypatch.setenv("NO_PROXY", "") + client = request.getfixturevalue(fixture_name) + + response = await client.get("/mock-only") + + assert response.status_code == 200 + assert response.json()["url"] == "https://mock.api.com/mock-only" + + +@pytest.mark.parametrize("share_http", [False, True]) +async def test_request_capture_clones_preserve_mock_transport_with_proxy( + share_http: bool, request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:1") + monkeypatch.setenv("ALL_PROXY", "http://127.0.0.1:1") + monkeypatch.setenv("NO_PROXY", "") + client = request.getfixturevalue("request_capture") + clone = client.clone(ClientOptions(base_url="https://cloned.api.com"), share_http=share_http) + nested_clone = clone.clone() + + response = await nested_clone.get("/mock-only") + + assert response.status_code == 200 + assert response.json()["url"] == "https://cloned.api.com/mock-only" + assert len(client._capture.requests) == 1 + assert (clone.http is client.http) == share_http + + @pytest.mark.unit class TestFixtures: """Test deserialization of various activity fixtures.""" diff --git a/packages/common/tests/test_client.py b/packages/common/tests/test_client.py index f5244bd09..6df494e41 100644 --- a/packages/common/tests/test_client.py +++ b/packages/common/tests/test_client.py @@ -67,6 +67,19 @@ def sync_token_factory() -> str: return "sync-token" +def create_mock_client(transport: httpx.MockTransport, options: ClientOptions | None = None) -> Client: + options = options or ClientOptions(base_url="https://example.com") + return Client( + options, + _http=httpx.AsyncClient( + transport=transport, + base_url=options.base_url or "", + headers=options.headers, + timeout=options.timeout, + ), + ) + + @pytest.fixture def mock_transport(): def handler(request): @@ -76,17 +89,22 @@ def handler(request): @pytest.mark.asyncio -async def test_get_request_merges_headers_and_token(mock_transport): +@pytest.mark.parametrize("proxy", [None, "http://127.0.0.1:1"]) +async def test_get_request_merges_headers_and_token(mock_transport, monkeypatch, proxy): + if proxy is not None: + monkeypatch.setenv("HTTPS_PROXY", proxy) + monkeypatch.setenv("ALL_PROXY", proxy) + monkeypatch.setenv("NO_PROXY", "") interceptor = DummyAsyncInterceptor() - client = Client( + client = create_mock_client( + mock_transport, ClientOptions( base_url="https://example.com", headers={"X-Default": "foo"}, token="abc123", interceptors=[interceptor], - ) + ), ) - client.http._transport = mock_transport resp = await client.get("/test", headers={"X-Custom": "bar"}, token="override") data = resp.json() @@ -104,13 +122,13 @@ async def test_get_request_merges_headers_and_token(mock_transport): ) @pytest.mark.asyncio async def test_post_request_and_response_interceptor(mock_transport, interceptor): - client = Client( + client = create_mock_client( + mock_transport, ClientOptions( base_url="https://example.com", interceptors=[interceptor], - ) + ), ) - client.http._transport = mock_transport resp = await client.post("/post", data={"payload": "test_data"}) assert resp.status_code == 200 @@ -121,18 +139,24 @@ async def test_post_request_and_response_interceptor(mock_transport, interceptor @pytest.mark.asyncio async def test_clone_merges_options_and_interceptors(mock_transport): interceptor1 = DummyAsyncInterceptor() - client = Client( + client = create_mock_client( + mock_transport, ClientOptions( base_url="https://example.com", headers={"X-Default": "foo"}, interceptors=[interceptor1], - ) + ), ) - client.http._transport = mock_transport interceptor2 = DummyAsyncInterceptor() clone = client.clone(ClientOptions(headers={"X-Clone": "bar"}, interceptors=[interceptor2])) - clone.http._transport = mock_transport + clone.http = httpx.AsyncClient( + transport=mock_transport, + base_url=clone.http.base_url, + headers=clone.http.headers, + timeout=clone.http.timeout, + event_hooks=clone.http.event_hooks, + ) resp = await clone.get("/clone") assert resp.status_code == 200 @@ -210,8 +234,7 @@ def handler(request: httpx.Request) -> httpx.Response: events.append("terminal") return httpx.Response(200, json={"ok": True}, headers={"content-type": "application/json"}) - client = Client(ClientOptions(base_url="https://example.com")) - client.http._transport = httpx.MockTransport(handler) + client = create_mock_client(httpx.MockTransport(handler)) client.use(RecordingMiddleware("one", events)) client.use(RecordingMiddleware("two", events)) @@ -244,8 +267,7 @@ async def send(self, context: MiddlewareContext, next: MiddlewareNext) -> httpx. context.kwargs["extensions"] = {"middleware": "extension-value"} return await next() - client = Client(ClientOptions(base_url="https://example.com")) - client.http._transport = httpx.MockTransport(handler) + client = create_mock_client(httpx.MockTransport(handler)) client.use(MutatingMiddleware()) response = await client.get("/mutated") @@ -259,8 +281,7 @@ class ShortCircuitMiddleware: def send(self, context: MiddlewareContext, next: MiddlewareNext) -> httpx.Response: return httpx.Response(204) - client = Client(ClientOptions(base_url="https://example.com")) - client.http._transport = httpx.MockTransport(lambda request: httpx.Response(500)) + client = create_mock_client(httpx.MockTransport(lambda request: httpx.Response(500))) client.use(ShortCircuitMiddleware()) response = await client.get("/short-circuit") @@ -284,8 +305,7 @@ async def send(self, context: MiddlewareContext, next: MiddlewareNext) -> httpx. events.append(str(exception)) raise - client = Client(ClientOptions(base_url="https://example.com")) - client.http._transport = httpx.MockTransport(handler) + client = create_mock_client(httpx.MockTransport(handler)) client.use(ErrorRecordingMiddleware()) with pytest.raises(RuntimeError, match="boom"): @@ -312,8 +332,9 @@ async def send(self, context: MiddlewareContext, next: MiddlewareNext) -> httpx. events.append("middleware:after") return response - client = Client(ClientOptions(base_url="https://example.com", interceptors=[RecordingInterceptor()])) - client.http._transport = mock_transport + client = create_mock_client( + mock_transport, ClientOptions(base_url="https://example.com", interceptors=[RecordingInterceptor()]) + ) client.use(EventMiddleware()) await client.get("/with-interceptors") @@ -339,8 +360,7 @@ async def send(self, context: MiddlewareContext, next: MiddlewareNext) -> httpx. metadata = object() middleware = MetadataMiddleware() - client = Client(ClientOptions(base_url="https://example.com")) - client.http._transport = httpx.MockTransport(handler) + client = create_mock_client(httpx.MockTransport(handler)) client.use(middleware) await client.get("/metadata", _metadata=metadata) @@ -386,13 +406,13 @@ def test_clone_can_clear_interceptors_with_empty_override(): ) @pytest.mark.asyncio async def test_token_types(mock_transport, token, expected): - client = Client( + client = create_mock_client( + mock_transport, ClientOptions( base_url="https://example.com", token=token, - ) + ), ) - client.http._transport = mock_transport resp = await client.get("/token-test") data = resp.json() @@ -410,13 +430,13 @@ async def async_none_factory() -> None: @pytest.mark.asyncio async def test_async_none_token(mock_transport): - client = Client( + client = create_mock_client( + mock_transport, ClientOptions( base_url="https://example.com", token=async_none_factory, - ) + ), ) - client.http._transport = mock_transport resp = await client.get("/token-test") data = resp.json() @@ -425,8 +445,7 @@ async def test_async_none_token(mock_transport): @pytest.mark.asyncio async def test_explicit_authorization_header_wins_over_default_token(mock_transport): - client = Client(ClientOptions(base_url="https://example.com", token="default-token")) - client.http._transport = mock_transport + client = create_mock_client(mock_transport, ClientOptions(base_url="https://example.com", token="default-token")) resp = await client.get("/token-test", headers={"Authorization": "******"}) data = resp.json() @@ -436,14 +455,14 @@ async def test_explicit_authorization_header_wins_over_default_token(mock_transp @pytest.mark.asyncio async def test_default_authorization_header_bypasses_token_resolution(mock_transport): - client = Client( + client = create_mock_client( + mock_transport, ClientOptions( base_url="https://example.com", headers={"Authorization": "******"}, token=failing_token_factory, - ) + ), ) - client.http._transport = mock_transport resp = await client.get("/token-test") data = resp.json() @@ -458,13 +477,13 @@ def failing_token_factory() -> str: @pytest.mark.asyncio async def test_failing_token_factory(mock_transport): - client = Client( + client = create_mock_client( + mock_transport, ClientOptions( base_url="https://example.com", token=failing_token_factory, - ) + ), ) - client.http._transport = mock_transport with pytest.raises(ValueError, match="Token factory failed"): await client.get("/token-test")