|
5 | 5 | import re |
6 | 6 | import time |
7 | 7 | import typing |
8 | | -import urllib.parse |
9 | 8 | from contextlib import asynccontextmanager, contextmanager |
10 | 9 | from random import random |
11 | 10 |
|
@@ -123,6 +122,30 @@ def _should_retry(response: httpx.Response) -> bool: |
123 | 122 | return response.status_code >= 500 or response.status_code in retryable_400s |
124 | 123 |
|
125 | 124 |
|
| 125 | +def _build_url(base_url: str, path: typing.Optional[str]) -> str: |
| 126 | + """ |
| 127 | + Build a full URL by joining a base URL with a path. |
| 128 | +
|
| 129 | + This function correctly handles base URLs that contain path prefixes (e.g., tenant-based URLs) |
| 130 | + by using string concatenation instead of urllib.parse.urljoin(), which would incorrectly |
| 131 | + strip path components when the path starts with '/'. |
| 132 | +
|
| 133 | + Example: |
| 134 | + >>> _build_url("https://cloud.example.com/org/tenant/api", "/users") |
| 135 | + 'https://cloud.example.com/org/tenant/api/users' |
| 136 | +
|
| 137 | + Args: |
| 138 | + base_url: The base URL, which may contain path prefixes. |
| 139 | + path: The path to append. Can be None or empty string. |
| 140 | +
|
| 141 | + Returns: |
| 142 | + The full URL with base_url and path properly joined. |
| 143 | + """ |
| 144 | + if not path: |
| 145 | + return base_url |
| 146 | + return f"{base_url.rstrip('/')}/{path.lstrip('/')}" |
| 147 | + |
| 148 | + |
126 | 149 | def _maybe_filter_none_from_multipart_data( |
127 | 150 | data: typing.Optional[typing.Any], |
128 | 151 | request_files: typing.Optional[RequestFiles], |
@@ -294,7 +317,7 @@ def request( |
294 | 317 |
|
295 | 318 | response = self.httpx_client.request( |
296 | 319 | method=method, |
297 | | - url=urllib.parse.urljoin(f"{base_url}/", path), |
| 320 | + url=_build_url(base_url, path), |
298 | 321 | headers=jsonable_encoder( |
299 | 322 | remove_none_from_dict( |
300 | 323 | { |
@@ -397,7 +420,7 @@ def stream( |
397 | 420 |
|
398 | 421 | with self.httpx_client.stream( |
399 | 422 | method=method, |
400 | | - url=urllib.parse.urljoin(f"{base_url}/", path), |
| 423 | + url=_build_url(base_url, path), |
401 | 424 | headers=jsonable_encoder( |
402 | 425 | remove_none_from_dict( |
403 | 426 | { |
@@ -515,7 +538,7 @@ async def request( |
515 | 538 | # Add the input to each of these and do None-safety checks |
516 | 539 | response = await self.httpx_client.request( |
517 | 540 | method=method, |
518 | | - url=urllib.parse.urljoin(f"{base_url}/", path), |
| 541 | + url=_build_url(base_url, path), |
519 | 542 | headers=jsonable_encoder( |
520 | 543 | remove_none_from_dict( |
521 | 544 | { |
@@ -620,7 +643,7 @@ async def stream( |
620 | 643 |
|
621 | 644 | async with self.httpx_client.stream( |
622 | 645 | method=method, |
623 | | - url=urllib.parse.urljoin(f"{base_url}/", path), |
| 646 | + url=_build_url(base_url, path), |
624 | 647 | headers=jsonable_encoder( |
625 | 648 | remove_none_from_dict( |
626 | 649 | { |
|
0 commit comments