Skip to content

Commit aac21b0

Browse files
fix auth
1 parent 07a361e commit aac21b0

3 files changed

Lines changed: 148 additions & 11 deletions

File tree

drift/instrumentation/httpx/e2e-tests/src/app.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -360,13 +360,6 @@ async def fetch():
360360
except Exception as e:
361361
return jsonify({"error": str(e)}), 500
362362

363-
364-
# =============================================================================
365-
# BUG HUNTING TESTS - Confirmed Instrumentation Bugs
366-
# These endpoints expose bugs in the httpx instrumentation
367-
# =============================================================================
368-
369-
370363
@app.route("/test/streaming", methods=["GET"])
371364
def test_streaming():
372365
"""Test 5: Streaming response using client.stream() context manager."""
@@ -462,6 +455,28 @@ async def fetch():
462455
except Exception as e:
463456
return jsonify({"error": str(e)}), 500
464457

458+
@app.route("/test/basic-auth", methods=["GET"])
459+
def test_basic_auth():
460+
"""BUG: HTTP Basic Authentication fails in REPLAY mode.
461+
462+
Root cause: During REPLAY, the instrumentation intercepts the request BEFORE
463+
the auth flow runs, so the Authorization header is missing. But during RECORD,
464+
the auth flow runs and modifies the request IN PLACE before the span is finalized,
465+
so the header is captured. This causes a mismatch during replay.
466+
467+
See BUG_TRACKING.md for full analysis.
468+
"""
469+
try:
470+
with httpx.Client() as client:
471+
# httpbin.org/basic-auth/{user}/{passwd} returns 200 if auth succeeds
472+
response = client.get(
473+
"https://httpbin.org/basic-auth/testuser/testpass",
474+
auth=("testuser", "testpass"),
475+
)
476+
return jsonify(response.json())
477+
except Exception as e:
478+
return jsonify({"error": str(e)}), 500
479+
465480

466481
if __name__ == "__main__":
467482
sdk.mark_app_as_ready()

drift/instrumentation/httpx/e2e-tests/src/test_requests.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,6 @@ def make_request(method, endpoint, **kwargs):
112112

113113
make_request("GET", "/test/follow-redirects")
114114

115+
make_request("GET", "/test/basic-auth")
116+
115117
print("\nAll requests completed successfully")

drift/instrumentation/httpx/instrumentation.py

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,109 @@ def _create_client_span(self, method: str, url: str, is_pre_app_start: bool) ->
156156
)
157157
)
158158

159+
def _build_auth_from_param(self, auth: Any, httpx_module: Any) -> Any:
160+
"""Convert auth parameter to an httpx Auth object.
161+
162+
Mirrors httpx's BaseClient._build_auth() logic:
163+
- tuple -> BasicAuth(username, password)
164+
- Auth instance -> pass through
165+
- callable -> FunctionAuth(func)
166+
167+
Args:
168+
auth: Auth parameter (tuple, callable, Auth instance, or None)
169+
httpx_module: The httpx module (for accessing Auth classes)
170+
171+
Returns:
172+
httpx.Auth instance or None
173+
"""
174+
if auth is None:
175+
return None
176+
177+
# Tuple -> BasicAuth (username, password)
178+
if isinstance(auth, tuple):
179+
return httpx_module.BasicAuth(username=auth[0], password=auth[1])
180+
181+
# Check if it's an Auth subclass
182+
try:
183+
if hasattr(httpx_module, "Auth") and isinstance(auth, httpx_module.Auth):
184+
return auth
185+
except TypeError:
186+
# isinstance check failed, try duck typing
187+
if hasattr(auth, "sync_auth_flow") and hasattr(auth, "auth_flow"):
188+
return auth
189+
190+
# Callable -> FunctionAuth
191+
if callable(auth):
192+
return httpx_module.FunctionAuth(func=auth)
193+
194+
return None
195+
196+
def _apply_auth_to_request_sync(
197+
self,
198+
request: Any,
199+
auth: Any,
200+
httpx_module: Any,
201+
client: Any = None,
202+
) -> Any:
203+
"""Apply auth flow to request to add Authorization header.
204+
205+
Runs the auth flow generator to get the modified request.
206+
For BasicAuth/FunctionAuth, this adds the Authorization header.
207+
For complex auth (DigestAuth), only gets the first request.
208+
209+
Args:
210+
request: httpx.Request object
211+
auth: Auth parameter (tuple, callable, Auth instance, USE_CLIENT_DEFAULT, or None)
212+
httpx_module: The httpx module
213+
client: The httpx Client instance (needed to resolve USE_CLIENT_DEFAULT)
214+
215+
Returns:
216+
Modified request with auth headers applied (for simple auth types)
217+
"""
218+
# Handle USE_CLIENT_DEFAULT sentinel - get auth from client's default
219+
# httpx uses this when auth is set on the client, not the request
220+
if client is not None and auth is not None:
221+
# Check if auth is USE_CLIENT_DEFAULT (a sentinel class instance)
222+
auth_type_name = type(auth).__name__
223+
if auth_type_name == "UseClientDefault":
224+
# Get the client's default auth
225+
auth = getattr(client, "_auth", None)
226+
227+
# Convert auth param to Auth object
228+
auth_obj = self._build_auth_from_param(auth, httpx_module)
229+
230+
# Check for URL-embedded credentials if no explicit auth
231+
if auth_obj is None:
232+
try:
233+
username = request.url.username
234+
password = request.url.password
235+
if username or password:
236+
auth_obj = httpx_module.BasicAuth(
237+
username=username or "",
238+
password=password or "",
239+
)
240+
except Exception:
241+
pass
242+
243+
if auth_obj is None:
244+
return request
245+
246+
try:
247+
# Get the sync auth flow generator
248+
auth_flow = auth_obj.sync_auth_flow(request)
249+
250+
# Get the first (and for BasicAuth/FunctionAuth, only) request
251+
# This modifies the request in-place for BasicAuth (adds Authorization header)
252+
modified_request = next(auth_flow)
253+
254+
# Close the generator to clean up
255+
auth_flow.close()
256+
257+
return modified_request
258+
except Exception as e:
259+
logger.warning(f"Error applying auth flow in replay mode: {e}")
260+
return request
261+
159262
def _patch_sync_client(self, module: Any) -> None:
160263
"""Patch httpx.Client.send for sync HTTP calls.
161264
@@ -189,7 +292,9 @@ def original_call():
189292
# REPLAY mode: Use handle_replay_mode for proper background request handling
190293
if sdk.mode == TuskDriftMode.REPLAY:
191294
return handle_replay_mode(
192-
replay_mode_handler=lambda: instrumentation_self._handle_replay_send_sync(sdk, module, request),
295+
replay_mode_handler=lambda: instrumentation_self._handle_replay_send_sync(
296+
sdk, module, request, auth=auth, client=client_self
297+
),
193298
no_op_request_handler=lambda: instrumentation_self._get_default_response(module, url_str),
194299
is_server_request=False,
195300
)
@@ -218,12 +323,20 @@ def _handle_replay_send_sync(
218323
sdk: TuskDrift,
219324
httpx_module: Any,
220325
request: Any, # httpx.Request object
326+
auth: Any = None, # Auth parameter to apply before mock lookup
327+
client: Any = None, # Client instance (needed to resolve USE_CLIENT_DEFAULT)
221328
) -> Any:
222329
"""Handle send in REPLAY mode (sync).
223330
224-
Creates a span, fetches mock response from the Request object, and returns it.
331+
Creates a span, applies auth to get modified request, fetches mock response.
225332
Raises RuntimeError if no mock is found.
226333
"""
334+
# Apply auth flow to get request with Authorization header
335+
# This is needed because during RECORD, auth is applied by httpx internally,
336+
# but during REPLAY we skip httpx and need to apply it ourselves
337+
if auth is not None:
338+
request = self._apply_auth_to_request_sync(request, auth, httpx_module, client)
339+
227340
method = request.method
228341
url = str(request.url)
229342

@@ -354,7 +467,9 @@ async def original_call():
354467
# handle_replay_mode returns coroutine which we await
355468
if sdk.mode == TuskDriftMode.REPLAY:
356469
return await handle_replay_mode(
357-
replay_mode_handler=lambda: instrumentation_self._handle_replay_send_async(sdk, module, request),
470+
replay_mode_handler=lambda: instrumentation_self._handle_replay_send_async(
471+
sdk, module, request, auth=auth, client=client_self
472+
),
358473
no_op_request_handler=lambda: instrumentation_self._get_default_response(module, url_str),
359474
is_server_request=False,
360475
)
@@ -384,13 +499,18 @@ async def _handle_replay_send_async(
384499
sdk: TuskDrift,
385500
httpx_module: Any,
386501
request: Any, # httpx.Request object
502+
auth: Any = None, # Auth parameter to apply before mock lookup
503+
client: Any = None, # Client instance (needed to resolve USE_CLIENT_DEFAULT)
387504
) -> Any:
388505
"""Handle send in REPLAY mode (async).
389506
390507
Delegates to sync version since mock lookup uses sync operations
391508
to avoid nested event loop issues with Flask's asyncio.run().
509+
510+
Note: Auth is applied synchronously since the auth flow for simple auth types
511+
(BasicAuth, FunctionAuth) does not require async operations.
392512
"""
393-
return self._handle_replay_send_sync(sdk, httpx_module, request)
513+
return self._handle_replay_send_sync(sdk, httpx_module, request, auth=auth, client=client)
394514

395515
async def _handle_record_send_async(
396516
self,

0 commit comments

Comments
 (0)