Skip to content

Commit 57f69fe

Browse files
refactor: complete hard switch to httpx2
Remove the try/except dual-import blocks; direct 'import httpx2' everywhere. pyproject: httpx2>=2.12.0 as the only runtime HTTP dep; requires-python bumped to >=3.10 (httpx2's floor; 3.8/3.9 are EOL). Test infra: vcrpy and respx only patch/validate httpx 0.x, so add tests/_vcr_httpx2_stubs.py (vcr send-stubs adapted for httpx2, wired via a conftest autouse fixture) and tests/_mock_router.py (a minimal respx-compatible Router, natively typed for httpx2, replacing respx). Full suite: 158 passed, 6 skipped (skips need a real API token). Refs: #470
1 parent f2fb991 commit 57f69fe

20 files changed

Lines changed: 657 additions & 1162 deletions

pyproject.toml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,9 @@ description = "Python client for Replicate"
99
readme = "README.md"
1010
license = { file = "LICENSE" }
1111
authors = [{ name = "Replicate, Inc." }]
12-
requires-python = ">=3.8"
12+
requires-python = ">=3.10"
1313
dependencies = [
14-
"httpx>=0.21.0,<1",
15-
"httpx2>=2.12.0; python_version >= \"3.10\"",
14+
"httpx2>=2.12.0",
1615
"packaging",
1716
"pydantic>1.10.7",
1817
"typing_extensions>=4.5.0",
@@ -29,7 +28,6 @@ dev-dependencies = [
2928
"pyright>=1.1.358",
3029
"pytest-asyncio>=0.23.6",
3130
"pytest-recording>=0.13.1",
32-
"respx>=0.21.1",
3331
"ruff>=0.3.7",
3432
]
3533

replicate/client.py

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,7 @@
1616
Union,
1717
)
1818

19-
try:
20-
import httpx2 as httpx
21-
except ImportError:
22-
import httpx
19+
import httpx2
2320
from typing_extensions import Unpack
2421

2522
from replicate.__about__ import __version__
@@ -43,15 +40,15 @@
4340
class Client:
4441
"""A Replicate API client library"""
4542

46-
__client: Optional[httpx.Client] = None
47-
__async_client: Optional[httpx.AsyncClient] = None
43+
__client: Optional[httpx2.Client] = None
44+
__async_client: Optional[httpx2.AsyncClient] = None
4845

4946
def __init__(
5047
self,
5148
api_token: Optional[str] = None,
5249
*,
5350
base_url: Optional[str] = None,
54-
timeout: Optional[httpx.Timeout] = None,
51+
timeout: Optional[httpx2.Timeout] = None,
5552
**kwargs,
5653
) -> None:
5754
super().__init__()
@@ -64,10 +61,10 @@ def __init__(
6461
self.poll_interval = float(os.environ.get("REPLICATE_POLL_INTERVAL", "0.5"))
6562

6663
@property
67-
def _client(self) -> httpx.Client:
64+
def _client(self) -> httpx2.Client:
6865
if not self.__client:
6966
self.__client = _build_httpx_client(
70-
httpx.Client,
67+
httpx2.Client,
7168
self._api_token,
7269
self._base_url,
7370
self._timeout,
@@ -76,24 +73,24 @@ def _client(self) -> httpx.Client:
7673
return self.__client # type: ignore[return-value]
7774

7875
@property
79-
def _async_client(self) -> httpx.AsyncClient:
76+
def _async_client(self) -> httpx2.AsyncClient:
8077
if not self.__async_client:
8178
self.__async_client = _build_httpx_client(
82-
httpx.AsyncClient,
79+
httpx2.AsyncClient,
8380
self._api_token,
8481
self._base_url,
8582
self._timeout,
8683
**self._client_kwargs,
8784
) # type: ignore[assignment]
8885
return self.__async_client # type: ignore[return-value]
8986

90-
def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
87+
def _request(self, method: str, path: str, **kwargs) -> httpx2.Response:
9188
resp = self._client.request(method, path, **kwargs)
9289
_raise_for_status(resp)
9390

9491
return resp
9592

96-
async def _async_request(self, method: str, path: str, **kwargs) -> httpx.Response:
93+
async def _async_request(self, method: str, path: str, **kwargs) -> httpx2.Response:
9794
resp = await self._async_client.request(method, path, **kwargs)
9895
_raise_for_status(resp)
9996

@@ -222,8 +219,8 @@ async def async_stream(
222219
return async_stream(self, ref, input, use_file_output=use_file_output, **params)
223220

224221

225-
# Adapted from https://github.com/encode/httpx/issues/108#issuecomment-1132753155
226-
class RetryTransport(httpx.AsyncBaseTransport, httpx.BaseTransport):
222+
# Adapted from https://github.com/encode/httpx2/issues/108#issuecomment-1132753155
223+
class RetryTransport(httpx2.AsyncBaseTransport, httpx2.BaseTransport):
227224
"""A custom HTTP transport that automatically retries requests using an exponential backoff strategy
228225
for specific HTTP status codes and request methods.
229226
"""
@@ -240,7 +237,7 @@ class RetryTransport(httpx.AsyncBaseTransport, httpx.BaseTransport):
240237

241238
def __init__( # pylint: disable=too-many-arguments
242239
self,
243-
wrapped_transport: Union[httpx.BaseTransport, httpx.AsyncBaseTransport],
240+
wrapped_transport: Union[httpx2.BaseTransport, httpx2.AsyncBaseTransport],
244241
*,
245242
max_attempts: int = 10,
246243
max_backoff_wait: float = MAX_BACKOFF_WAIT,
@@ -272,7 +269,7 @@ def __init__( # pylint: disable=too-many-arguments
272269
self.max_backoff_wait = max_backoff_wait
273270

274271
def _calculate_sleep(
275-
self, attempts_made: int, headers: Union[httpx.Headers, Mapping[str, str]]
272+
self, attempts_made: int, headers: Union[httpx2.Headers, Mapping[str, str]]
276273
) -> float:
277274
retry_after_header = (headers.get("Retry-After") or "").strip()
278275
if retry_after_header:
@@ -292,7 +289,7 @@ def _calculate_sleep(
292289
total_backoff = backoff + jitter
293290
return min(total_backoff, self.max_backoff_wait)
294291

295-
def handle_request(self, request: httpx.Request) -> httpx.Response:
292+
def handle_request(self, request: httpx2.Request) -> httpx2.Response:
296293
response = self._wrapped_transport.handle_request(request) # type: ignore
297294

298295
if request.method not in self.retryable_methods:
@@ -318,7 +315,7 @@ def handle_request(self, request: httpx.Request) -> httpx.Response:
318315
attempts_made += 1
319316
remaining_attempts -= 1
320317

321-
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
318+
async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
322319
response = await self._wrapped_transport.handle_async_request(request) # type: ignore
323320

324321
if request.method not in self.retryable_methods:
@@ -366,12 +363,12 @@ def _get_api_token_from_environment() -> Optional[str]:
366363

367364

368365
def _build_httpx_client(
369-
client_type: Type[Union[httpx.Client, httpx.AsyncClient]],
366+
client_type: Type[Union[httpx2.Client, httpx2.AsyncClient]],
370367
api_token: Optional[str] = None,
371368
base_url: Optional[str] = None,
372-
timeout: Optional[httpx.Timeout] = None,
369+
timeout: Optional[httpx2.Timeout] = None,
373370
**kwargs,
374-
) -> Union[httpx.Client, httpx.AsyncClient]:
371+
) -> Union[httpx2.Client, httpx2.AsyncClient]:
375372
headers = kwargs.pop("headers", {})
376373
if "User-Agent" not in headers:
377374
headers["User-Agent"] = f"replicate-python/{__version__}"
@@ -386,14 +383,14 @@ def _build_httpx_client(
386383
if base_url == "":
387384
base_url = "https://api.replicate.com"
388385

389-
timeout = timeout or httpx.Timeout(
386+
timeout = timeout or httpx2.Timeout(
390387
5.0, read=30.0, write=30.0, connect=5.0, pool=10.0
391388
)
392389

393390
transport = kwargs.pop("transport", None) or (
394-
httpx.HTTPTransport()
395-
if client_type is httpx.Client
396-
else httpx.AsyncHTTPTransport()
391+
httpx2.HTTPTransport()
392+
if client_type is httpx2.Client
393+
else httpx2.AsyncHTTPTransport()
397394
)
398395

399396
return client_type(
@@ -405,6 +402,6 @@ def _build_httpx_client(
405402
)
406403

407404

408-
def _raise_for_status(resp: httpx.Response) -> None:
405+
def _raise_for_status(resp: httpx2.Response) -> None:
409406
if 400 <= resp.status_code < 600:
410407
raise ReplicateError.from_response(resp)

replicate/exceptions.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
from typing import TYPE_CHECKING, Optional
22

3-
try:
4-
import httpx2 as httpx
5-
except ImportError:
6-
import httpx
3+
import httpx2
74

85
if TYPE_CHECKING:
96
from replicate.prediction import Prediction
@@ -60,7 +57,7 @@ def __init__( # pylint: disable=too-many-arguments
6057
self.instance = instance
6158

6259
@classmethod
63-
def from_response(cls, response: httpx.Response) -> "ReplicateError":
60+
def from_response(cls, response: httpx2.Response) -> "ReplicateError":
6461
"""Create a ReplicateError from an HTTP response."""
6562

6663
try:

replicate/helpers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from types import GeneratorType
77
from typing import TYPE_CHECKING, Any, AsyncIterator, Iterator, Optional
88

9-
import httpx
9+
import httpx2
1010

1111
if TYPE_CHECKING:
1212
from replicate.client import Client
@@ -115,7 +115,7 @@ def base64_encode_file(file: io.IOBase) -> str:
115115
return f"data:{mime_type};base64,{encoded_body}"
116116

117117

118-
class FileOutput(httpx.SyncByteStream, httpx.AsyncByteStream):
118+
class FileOutput(httpx2.SyncByteStream, httpx2.AsyncByteStream):
119119
"""
120120
An object that can be used to read the contents of an output file
121121
created by running a Replicate model.

replicate/prediction.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,7 @@
1616
overload,
1717
)
1818

19-
try:
20-
import httpx2 as httpx
21-
except ImportError:
22-
import httpx
19+
import httpx2
2320
from typing_extensions import NotRequired, TypedDict, Unpack
2421

2522
from replicate.exceptions import ModelError, ReplicateError
@@ -629,7 +626,7 @@ async def async_cancel(self, id: str) -> Prediction:
629626

630627
class CreatePredictionRequestParams(TypedDict):
631628
headers: NotRequired[Optional[dict]]
632-
timeout: NotRequired[Optional[httpx.Timeout]]
629+
timeout: NotRequired[Optional[httpx2.Timeout]]
633630

634631

635632
def _create_prediction_request_params(
@@ -646,9 +643,9 @@ def _create_prediction_request_params(
646643

647644
def _create_prediction_timeout(
648645
*, wait: Optional[Union[int, bool]] = None
649-
) -> Union[httpx.Timeout, None]:
646+
) -> Union[httpx2.Timeout, None]:
650647
"""
651-
Returns an `httpx.Timeout` instances appropriate for the optional
648+
Returns an `httpx2.Timeout` instances appropriate for the optional
652649
`Prefer: wait=x` header that can be provided with the request. This
653650
will ensure that we give the server enough time to respond with
654651
a partial prediction in the event that the request times out.
@@ -658,7 +655,7 @@ def _create_prediction_timeout(
658655
return None
659656

660657
read_timeout = 60.0 if isinstance(wait, bool) else wait
661-
return httpx.Timeout(5.0, read=read_timeout + 0.5)
658+
return httpx2.Timeout(5.0, read=read_timeout + 0.5)
662659

663660

664661
def _create_prediction_headers(

replicate/stream.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,7 @@
1010
Union,
1111
)
1212

13-
try:
14-
import httpx2 as httpx
15-
except ImportError:
16-
import httpx
13+
import httpx2
1714
from typing_extensions import Unpack
1815

1916
from replicate import identifier
@@ -67,13 +64,13 @@ class EventSource:
6764
"""
6865

6966
client: "Client"
70-
response: "httpx.Response"
67+
response: "httpx2.Response"
7168
use_file_output: bool
7269

7370
def __init__(
7471
self,
7572
client: "Client",
76-
response: "httpx.Response",
73+
response: "httpx2.Response",
7774
*,
7875
use_file_output: Optional[bool] = True,
7976
) -> None:

replicate/webhook.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,7 @@
1111
from replicate.resource import Namespace, Resource
1212

1313
if TYPE_CHECKING:
14-
try:
15-
import httpx2 as httpx
16-
except ImportError:
17-
import httpx
14+
import httpx2
1815

1916

2017
class WebhookSigningSecret(Resource):
@@ -94,7 +91,7 @@ async def async_secret(self) -> WebhookSigningSecret:
9491
@overload
9592
@staticmethod
9693
def validate(
97-
request: "httpx.Request",
94+
request: "httpx2.Request",
9895
secret: WebhookSigningSecret,
9996
tolerance: Optional[int] = None,
10097
) -> bool: ...
@@ -110,7 +107,7 @@ def validate(
110107

111108
@staticmethod
112109
def validate( # type: ignore # pylint: disable=too-many-branches,too-many-locals
113-
request: Optional["httpx.Request"] = None,
110+
request: Optional["httpx2.Request"] = None,
114111
headers: Optional[Dict[str, str]] = None,
115112
body: Optional[str] = None,
116113
secret: Optional[WebhookSigningSecret] = None,
@@ -120,7 +117,7 @@ def validate( # type: ignore # pylint: disable=too-many-branches,too-many-local
120117
Validate the signature from an incoming webhook request using the provided secret.
121118
122119
Args:
123-
request (httpx.Request): The request object.
120+
request (httpx2.Request): The request object.
124121
headers (Dict[str, str]): The request headers.
125122
body (str): The request body.
126123
secret (WebhookSigningSecret): The webhook signing secret.

0 commit comments

Comments
 (0)