Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ dev = [
"inline-snapshot>=0.28.0",
"griffe>=1",
"http-snapshot[httpx]==0.1.8",
"httpx2 ; python_version >= '3.10'",
]
pydantic-v1 = [
"pydantic>=1.9.0,<2",
Expand Down Expand Up @@ -159,6 +160,7 @@ exclude = [
".venv",
".nox",
"examples/mcp_tool_runner.py", # mcp requires Python 3.10+, lint runs on 3.9
"tests/test_httpx2_client.py", # httpx2 requires Python 3.10+, lint runs on 3.9
]

reportImplicitOverride = true
Expand Down
20 changes: 18 additions & 2 deletions requirements-dev.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ anyio==4.12.1
# via
# anthropic
# httpx
# httpx2
asttokens==3.0.1
# via inline-snapshot
backports-asyncio-runner==1.2.0 ; python_full_version < '3.11'
Expand Down Expand Up @@ -45,19 +46,29 @@ griffelib==2.0.0 ; python_full_version >= '3.10'
# griffe
# griffecli
h11==0.16.0
# via httpcore
# via
# httpcore
# httpcore2
http-snapshot==0.1.8
httpcore==1.0.9
# via httpx
httpcore2==2.7.0 ; python_full_version >= '3.10'
# via httpx2
httpx==0.28.1
# via
# anthropic
# http-snapshot
# respx
idna==3.11
httpx2==2.7.0 ; python_full_version >= '3.10'
idna==3.11 ; python_full_version < '3.10'
# via
# anyio
# httpx
idna==3.18 ; python_full_version >= '3.10'
# via
# anyio
# httpx
# httpx2
importlib-metadata==8.7.1
iniconfig==2.1.0 ; python_full_version < '3.10'
# via pytest
Expand Down Expand Up @@ -123,6 +134,10 @@ tomli==2.4.0 ; python_full_version < '3.11'
# inline-snapshot
# mypy
# pytest
truststore==0.10.4 ; python_full_version >= '3.10'
# via
# httpcore2
# httpx2
types-awscrt==0.31.3
# via botocore-stubs
types-s3transfer==0.16.0
Expand All @@ -133,6 +148,7 @@ typing-extensions==4.15.0
# anyio
# boto3-stubs
# exceptiongroup
# httpx2
# inline-snapshot
# mypy
# pydantic
Expand Down
32 changes: 27 additions & 5 deletions src/anthropic/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,20 @@
HTTPX_DEFAULT_TIMEOUT = Timeout(5.0)


# httpx2 (https://github.com/pydantic/httpx2) is an API-compatible fork of httpx that
# lives in a separate import namespace, so `httpx2.Client` & co. are distinct classes
# that fail `isinstance(..., httpx.Client)`. Accept an injected httpx2 client when the
# package is installed.
try:
import httpx2 # type: ignore
except ImportError:
httpx2 = None

_SYNC_HTTP_CLIENT_TYPES = (httpx.Client,) if httpx2 is None else (httpx.Client, httpx2.Client)
_ASYNC_HTTP_CLIENT_TYPES = (httpx.AsyncClient,) if httpx2 is None else (httpx.AsyncClient, httpx2.AsyncClient)
_TIMEOUT_EXCEPTIONS = (httpx.TimeoutException,) if httpx2 is None else (httpx.TimeoutException, httpx2.TimeoutException)


class PageInfo:
"""Stores the necessary information to build the request to retrieve the next page.

Expand Down Expand Up @@ -599,12 +613,20 @@ def _build_request(
headers.pop("Content-Type", None)
kwargs.pop("data", None)

# httpx2's `build_request` rejects a classic `httpx.URL`, so pass the URL as a
# string for httpx2 clients while leaving classic httpx clients untouched.
url = (
str(prepared_url)
if httpx2 is not None and isinstance(self._client, (httpx2.Client, httpx2.AsyncClient))
else prepared_url
)

# TODO: report this error to httpx
return self._client.build_request( # pyright: ignore[reportUnknownMemberType]
headers=headers,
timeout=self.timeout if isinstance(options.timeout, NotGiven) else options.timeout,
method=options.method,
url=prepared_url,
url=url,
# the `Query` type that we use is incompatible with qs'
# `Params` type as it needs to be typed as `Mapping[str, object]`
# so that passing a `TypedDict` doesn't cause an error.
Expand Down Expand Up @@ -1007,7 +1029,7 @@ def __init__(
else:
timeout = DEFAULT_TIMEOUT

if http_client is not None and not isinstance(http_client, httpx.Client): # pyright: ignore[reportUnnecessaryIsInstance]
if http_client is not None and not isinstance(http_client, _SYNC_HTTP_CLIENT_TYPES): # pyright: ignore[reportUnnecessaryIsInstance]
raise TypeError(
f"Invalid `http_client` argument; Expected an instance of `httpx.Client` but got {type(http_client)}"
)
Expand Down Expand Up @@ -1297,7 +1319,7 @@ def _attempt_request(
stream=stream or self._should_stream_response_body(request=request),
**kwargs,
)
except httpx.TimeoutException as err:
except _TIMEOUT_EXCEPTIONS as err:
log.debug("Encountered httpx.TimeoutException", exc_info=True)
raise APITimeoutError(request=request) from err
except Exception as err:
Expand Down Expand Up @@ -1749,7 +1771,7 @@ def __init__(
else:
timeout = DEFAULT_TIMEOUT

if http_client is not None and not isinstance(http_client, httpx.AsyncClient): # pyright: ignore[reportUnnecessaryIsInstance]
if http_client is not None and not isinstance(http_client, _ASYNC_HTTP_CLIENT_TYPES): # pyright: ignore[reportUnnecessaryIsInstance]
raise TypeError(
f"Invalid `http_client` argument; Expected an instance of `httpx.AsyncClient` but got {type(http_client)}"
)
Expand Down Expand Up @@ -2049,7 +2071,7 @@ async def _attempt_request(
stream=stream or self._should_stream_response_body(request=request),
**kwargs,
)
except httpx.TimeoutException as err:
except _TIMEOUT_EXCEPTIONS as err:
log.debug("Encountered httpx.TimeoutException", exc_info=True)
raise APITimeoutError(request=request) from err
except Exception as err:
Expand Down
17 changes: 15 additions & 2 deletions src/anthropic/_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@
from ._base_client import BaseClient


# httpx2 (https://github.com/pydantic/httpx2) raises its own `StreamConsumed` from a
# distinct import namespace, so an injected httpx2 client's double-read would escape an
# `except httpx.StreamConsumed`. Catch both when the package is installed.
try:
import httpx2 # type: ignore
except ImportError:
httpx2 = None

_STREAM_CONSUMED_EXCEPTIONS = (
(httpx.StreamConsumed,) if httpx2 is None else (httpx.StreamConsumed, httpx2.StreamConsumed)
)


P = ParamSpec("P")
R = TypeVar("R")
_T = TypeVar("_T")
Expand Down Expand Up @@ -361,7 +374,7 @@ def read(self) -> bytes:
"""Read and return the binary response content."""
try:
return self.http_response.read()
except httpx.StreamConsumed as exc:
except _STREAM_CONSUMED_EXCEPTIONS as exc:
# The default error raised by httpx isn't very
# helpful in our case so we re-raise it with
# a different error message.
Expand Down Expand Up @@ -468,7 +481,7 @@ async def read(self) -> bytes:
"""Read and return the binary response content."""
try:
return await self.http_response.aread()
except httpx.StreamConsumed as exc:
except _STREAM_CONSUMED_EXCEPTIONS as exc:
# the default error raised by httpx isn't very
# helpful in our case so we re-raise it with
# a different error message
Expand Down
63 changes: 63 additions & 0 deletions tests/test_httpx2_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from __future__ import annotations

import httpx
import pytest

from anthropic import Anthropic, AsyncAnthropic

httpx2 = pytest.importorskip("httpx2")

base_url = "http://127.0.0.1:4010"
api_key = "my-anthropic-api-key"


def test_accepts_httpx2_sync_client() -> None:
with httpx2.Client() as http_client:
client = Anthropic(
base_url=base_url,
api_key=api_key,
_strict_response_validation=True,
http_client=http_client,
)
assert isinstance(client._client, httpx2.Client)


async def test_accepts_httpx2_async_client() -> None:
async with httpx2.AsyncClient() as http_client:
client = AsyncAnthropic(
base_url=base_url,
api_key=api_key,
_strict_response_validation=True,
http_client=http_client,
)
assert isinstance(client._client, httpx2.AsyncClient)


def test_httpx2_sync_round_trip() -> None:
def handler(_request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(200, json={"ok": True})

with httpx2.Client(transport=httpx2.MockTransport(handler)) as http_client:
with Anthropic(
base_url=base_url,
api_key=api_key,
_strict_response_validation=True,
http_client=http_client,
) as client:
response = client.get("/foo", cast_to=httpx.Response)
assert response.status_code == 200


async def test_httpx2_async_round_trip() -> None:
def handler(_request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(200, json={"ok": True})

async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http_client:
async with AsyncAnthropic(
base_url=base_url,
api_key=api_key,
_strict_response_validation=True,
http_client=http_client,
) as client:
response = await client.get("/foo", cast_to=httpx.Response)
assert response.status_code == 200
Loading