diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index f8267ea45..d4e61b392 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -28,7 +28,7 @@ ) import backoff -import httpx +import httpx2 from opentelemetry import context as otel_context_api from opentelemetry import trace as otel_trace_api from opentelemetry.sdk.trace import ReadableSpan, TracerProvider @@ -201,8 +201,8 @@ class Langfuse: base_url (Optional[str]): The Langfuse API base URL. Defaults to "https://cloud.langfuse.com". Can also be set via LANGFUSE_BASE_URL environment variable. host (Optional[str]): Deprecated. Use base_url instead. The Langfuse API host URL. Defaults to "https://cloud.langfuse.com". timeout (Optional[int]): Timeout in seconds for API requests. Defaults to 5 seconds. - httpx_client (Optional[httpx.Client]): Custom httpx client for making non-tracing HTTP requests. If not provided, a default client will be created. - **Fork safety**: ``httpx.Client`` is thread-safe but not process-safe. When using + httpx_client (Optional[httpx2.Client]): Custom httpx2 client for making non-tracing HTTP requests. If not provided, a default client will be created. + **Fork safety**: ``httpx2.Client`` is thread-safe but not process-safe. When using ``fork()``-based servers (e.g. Gunicorn with ``--preload``), the SDK automatically recreates its internally-managed HTTP client in child processes after fork. A custom ``httpx_client`` is intentionally left as-is (the fork-inherited copy is reused), so @@ -318,7 +318,7 @@ def __init__( base_url: Optional[str] = None, host: Optional[str] = None, timeout: Optional[int] = None, - httpx_client: Optional[httpx.Client] = None, + httpx_client: Optional[httpx2.Client] = None, debug: bool = False, tracing_enabled: Optional[bool] = True, flush_at: Optional[int] = None, @@ -4219,11 +4219,11 @@ def update_prompt( return updated_prompt def _url_encode(self, url: str, *, is_url_param: Optional[bool] = False) -> str: - # httpx ≥ 0.28 does its own WHATWG-compliant quoting (eg. encodes bare + # httpx2 ≥ 0.28 does its own WHATWG-compliant quoting (eg. encodes bare # “%”, “?”, “#”, “|”, … in query/path parts). Re-quoting here would # double-encode, so we skip when the value is about to be sent straight - # to httpx (`is_url_param=True`) and the installed version is ≥ 0.28. - if is_url_param and Version(httpx.__version__) >= Version("0.28.0"): + # to httpx2 (`is_url_param=True`) and the installed version is ≥ 0.28. + if is_url_param and Version(httpx2.__version__) >= Version("0.28.0"): return url # urllib.parse.quote does not escape slashes "/" by default; we need to add safe="" to force escaping diff --git a/langfuse/_client/resource_manager.py b/langfuse/_client/resource_manager.py index a61101fe3..435ab4dd7 100644 --- a/langfuse/_client/resource_manager.py +++ b/langfuse/_client/resource_manager.py @@ -23,7 +23,7 @@ from queue import Full, Queue from typing import Any, Callable, Dict, List, Optional, cast -import httpx +import httpx2 from opentelemetry import trace as otel_trace_api from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import ReadableSpan, TracerProvider @@ -88,7 +88,7 @@ class LangfuseResourceManager: _ingestion_consumers: List[ScoreIngestionConsumer] @classmethod - def get_singleton_httpx_client(cls) -> Optional[httpx.Client]: + def get_singleton_httpx_client(cls) -> Optional[httpx2.Client]: with cls._lock: instances = list(cls._instances.values()) @@ -98,11 +98,11 @@ def get_singleton_httpx_client(cls) -> Optional[httpx.Client]: if len(instances) > 1: # Mirror get_client's safety stance: with multiple clients we # cannot tell which one produced a given reference, so fall back - # to a default httpx client rather than silently using an + # to a default httpx2 client rather than silently using an # arbitrary instance's transport config (proxy / CA / mTLS). langfuse_logger.warning( "Multiple Langfuse clients are instantiated; falling back to a " - "default httpx client for LangfuseMediaReference fetches. Pass an " + "default httpx2 client for LangfuseMediaReference fetches. Pass an " "explicit `client` to fetch_bytes/fetch_base64/fetch_data_uri to " "honor per-client transport settings." ) @@ -121,7 +121,7 @@ def __new__( timeout: Optional[int] = None, flush_at: Optional[int] = None, flush_interval: Optional[float] = None, - httpx_client: Optional[httpx.Client] = None, + httpx_client: Optional[httpx2.Client] = None, media_upload_thread_count: Optional[int] = None, sample_rate: Optional[float] = None, mask: Optional[MaskFunction] = None, @@ -189,7 +189,7 @@ def _initialize_instance( flush_at: Optional[int] = None, flush_interval: Optional[float] = None, media_upload_thread_count: Optional[int] = None, - httpx_client: Optional[httpx.Client] = None, + httpx_client: Optional[httpx2.Client] = None, sample_rate: Optional[float] = None, mask: Optional[MaskFunction] = None, mask_otel_spans: Optional[MaskOtelSpansFunction] = None, @@ -333,7 +333,7 @@ def _init_media_manager(self) -> None: def _init_api_clients(self) -> None: """Initialize HTTP-backed API clients. - Internally-managed httpx clients are recreated when this method is + Internally-managed httpx2 clients are recreated when this method is called after fork. Caller-provided clients are preserved because their lifecycle belongs to the caller. """ @@ -341,7 +341,7 @@ def _init_api_clients(self) -> None: self.httpx_client = self._custom_httpx_client else: client_headers = self.additional_headers if self.additional_headers else {} - self.httpx_client = httpx.Client( + self.httpx_client = httpx2.Client( timeout=self.timeout, headers=client_headers ) @@ -427,7 +427,7 @@ def _at_fork_reinit(self) -> None: if sys.platform == "darwin" and not urllib.request.getproxies_environment(): # urllib proxy discovery falls back to macOS SystemConfiguration APIs that - # are not safe to invoke after fork(). Setting no_proxy="*" makes httpx and + # are not safe to invoke after fork(). Setting no_proxy="*" makes httpx2 and # requests skip that lookup entirely in this child process. Skipped when # proxies are configured via environment variables: urllib then never touches # SystemConfiguration (no segfault risk), and overriding no_proxy would @@ -444,13 +444,13 @@ def _at_fork_reinit(self) -> None: # belong to the preloaded parent process and must not be processed by every # worker — otherwise uploads/scores would be duplicated across workers. # - # Internally-managed httpx clients must also be recreated: fork() duplicates the + # Internally-managed httpx2 clients must also be recreated: fork() duplicates the # parent's connection pool (TCP socket file descriptors) into the child. Both # processes then share the same underlying sockets, causing data corruption and # SSL/TLS state mismatch under concurrent use. Fresh clients start with an empty # pool owned solely by this child process. # - # Custom httpx clients provided by the caller are NOT recreated. The fork-inherited + # Custom httpx2 clients provided by the caller are NOT recreated. The fork-inherited # copy is reused as-is, giving the caller the opportunity to handle process-safety # themselves (e.g. by registering their own os.register_at_fork handler). try: diff --git a/langfuse/_task_manager/media_manager.py b/langfuse/_task_manager/media_manager.py index 9a66ecd63..5c34627c4 100644 --- a/langfuse/_task_manager/media_manager.py +++ b/langfuse/_task_manager/media_manager.py @@ -4,7 +4,7 @@ from typing import Any, Callable, Optional, TypeVar, cast import backoff -import httpx +import httpx2 from typing_extensions import ParamSpec from langfuse._client.environment_variables import LANGFUSE_MEDIA_UPLOAD_ENABLED @@ -34,7 +34,7 @@ def __init__( self, *, api_client: LangfuseAPI, - httpx_client: httpx.Client, + httpx_client: httpx2.Client, media_upload_queue: Queue, max_retries: Optional[int] = 3, ): @@ -50,7 +50,7 @@ def reinitialize( self, *, api_client: LangfuseAPI, - httpx_client: httpx.Client, + httpx_client: httpx2.Client, media_upload_queue: Queue, ) -> None: self._api_client = api_client @@ -454,7 +454,7 @@ def _process_upload_media_job( headers["x-ms-blob-type"] = "BlockBlob" headers["x-amz-checksum-sha256"] = data["content_sha256_hash"] - def _upload_with_status_check() -> httpx.Response: + def _upload_with_status_check() -> httpx2.Response: response = self._httpx_client.put( upload_url, headers=headers, @@ -468,7 +468,7 @@ def _upload_with_status_check() -> httpx.Response: try: upload_response = self._request_with_backoff(_upload_with_status_check) - except httpx.HTTPStatusError as e: + except httpx2.HTTPStatusError as e: upload_time_ms = int((time.time() - upload_start_time) * 1000) failed_response = e.response @@ -515,7 +515,7 @@ def _should_give_up(e: Exception) -> bool: and 400 <= e.status_code < 500 and e.status_code != 429 ) - if isinstance(e, httpx.HTTPStatusError): + if isinstance(e, httpx2.HTTPStatusError): return ( e.response is not None and e.response.status_code < 500 diff --git a/langfuse/_utils/request.py b/langfuse/_utils/request.py index 402d0b5a7..621b21a57 100644 --- a/langfuse/_utils/request.py +++ b/langfuse/_utils/request.py @@ -4,7 +4,7 @@ from base64 import b64encode from typing import Any, List, Union -import httpx +import httpx2 from langfuse._utils.serializer import EventSerializer from langfuse.logger import langfuse_logger as logger @@ -16,7 +16,7 @@ class LangfuseClient: _base_url: str _version: str _timeout: int - _session: httpx.Client + _session: httpx2.Client def __init__( self, @@ -25,7 +25,7 @@ def __init__( base_url: str, version: str, timeout: int, - session: httpx.Client, + session: httpx2.Client, ): self._public_key = public_key self._secret_key = secret_key @@ -46,7 +46,7 @@ def generate_headers(self) -> dict: "x-langfuse-public-key": self._public_key, } - def batch_post(self, **kwargs: Any) -> httpx.Response: + def batch_post(self, **kwargs: Any) -> httpx2.Response: """Post the `kwargs` to the batch API endpoint for events""" res = self.post(**kwargs) @@ -54,7 +54,7 @@ def batch_post(self, **kwargs: Any) -> httpx.Response: res, success_message="data uploaded successfully", return_json=False ) - def post(self, **kwargs: Any) -> httpx.Response: + def post(self, **kwargs: Any) -> httpx2.Response: """Post the `kwargs` to the API""" url = self._remove_trailing_slash(self._base_url) + "/api/public/ingestion" data = json.dumps(kwargs, cls=EventSerializer) @@ -76,8 +76,8 @@ def _remove_trailing_slash(self, url: str) -> str: return url def _process_response( - self, res: httpx.Response, success_message: str, *, return_json: bool = True - ) -> Union[httpx.Response, Any]: + self, res: httpx2.Response, success_message: str, *, return_json: bool = True + ) -> Union[httpx2.Response, Any]: logger.debug("received response: %s", res.text) if res.status_code in (200, 201): logger.debug(success_message) diff --git a/langfuse/api/client.py b/langfuse/api/client.py index 3781b4fcc..53313f6c4 100644 --- a/langfuse/api/client.py +++ b/langfuse/api/client.py @@ -4,7 +4,7 @@ import typing -import httpx +import httpx2 from .core.client_wrapper import AsyncClientWrapper, SyncClientWrapper if typing.TYPE_CHECKING: @@ -70,13 +70,13 @@ class LangfuseAPI: Additional headers to send with every request. timeout : typing.Optional[float] - The timeout to be used, in seconds, for requests. By default the timeout is 60 seconds, unless a custom httpx client is used, in which case this default is not enforced. + The timeout to be used, in seconds, for requests. By default the timeout is 60 seconds, unless a custom httpx2 client is used, in which case this default is not enforced. follow_redirects : typing.Optional[bool] - Whether the default httpx client follows redirects or not, this is irrelevant if a custom httpx client is passed in. + Whether the default httpx2 client follows redirects or not, this is irrelevant if a custom httpx2 client is passed in. - httpx_client : typing.Optional[httpx.Client] - The httpx client to use for making requests, a preconfigured client is used by default, however this is useful should you want to pass in any custom httpx configuration. + httpx_client : typing.Optional[httpx2.Client] + The httpx2 client to use for making requests, a preconfigured client is used by default, however this is useful should you want to pass in any custom httpx2 configuration. Examples -------- @@ -104,7 +104,7 @@ def __init__( headers: typing.Optional[typing.Dict[str, str]] = None, timeout: typing.Optional[float] = None, follow_redirects: typing.Optional[bool] = True, - httpx_client: typing.Optional[httpx.Client] = None, + httpx_client: typing.Optional[httpx2.Client] = None, ): _defaulted_timeout = ( timeout @@ -123,11 +123,11 @@ def __init__( headers=headers, httpx_client=httpx_client if httpx_client is not None - else httpx.Client( + else httpx2.Client( timeout=_defaulted_timeout, follow_redirects=follow_redirects ) if follow_redirects is not None - else httpx.Client(timeout=_defaulted_timeout), + else httpx2.Client(timeout=_defaulted_timeout), timeout=_defaulted_timeout, ) self._annotation_queues: typing.Optional[AnnotationQueuesClient] = None @@ -442,13 +442,13 @@ class AsyncLangfuseAPI: Additional headers to send with every request. timeout : typing.Optional[float] - The timeout to be used, in seconds, for requests. By default the timeout is 60 seconds, unless a custom httpx client is used, in which case this default is not enforced. + The timeout to be used, in seconds, for requests. By default the timeout is 60 seconds, unless a custom httpx2 client is used, in which case this default is not enforced. follow_redirects : typing.Optional[bool] - Whether the default httpx client follows redirects or not, this is irrelevant if a custom httpx client is passed in. + Whether the default httpx2 client follows redirects or not, this is irrelevant if a custom httpx2 client is passed in. - httpx_client : typing.Optional[httpx.AsyncClient] - The httpx client to use for making requests, a preconfigured client is used by default, however this is useful should you want to pass in any custom httpx configuration. + httpx_client : typing.Optional[httpx2.AsyncClient] + The httpx2 client to use for making requests, a preconfigured client is used by default, however this is useful should you want to pass in any custom httpx2 configuration. Examples -------- @@ -476,7 +476,7 @@ def __init__( headers: typing.Optional[typing.Dict[str, str]] = None, timeout: typing.Optional[float] = None, follow_redirects: typing.Optional[bool] = True, - httpx_client: typing.Optional[httpx.AsyncClient] = None, + httpx_client: typing.Optional[httpx2.AsyncClient] = None, ): _defaulted_timeout = ( timeout @@ -495,11 +495,11 @@ def __init__( headers=headers, httpx_client=httpx_client if httpx_client is not None - else httpx.AsyncClient( + else httpx2.AsyncClient( timeout=_defaulted_timeout, follow_redirects=follow_redirects ) if follow_redirects is not None - else httpx.AsyncClient(timeout=_defaulted_timeout), + else httpx2.AsyncClient(timeout=_defaulted_timeout), timeout=_defaulted_timeout, ) self._annotation_queues: typing.Optional[AsyncAnnotationQueuesClient] = None diff --git a/langfuse/api/core/client_wrapper.py b/langfuse/api/core/client_wrapper.py index 22bb6a70b..348c9eb81 100644 --- a/langfuse/api/core/client_wrapper.py +++ b/langfuse/api/core/client_wrapper.py @@ -2,7 +2,7 @@ import typing -import httpx +import httpx2 from .http_client import AsyncHttpClient, HttpClient @@ -36,7 +36,7 @@ def get_headers(self) -> typing.Dict[str, str]: username = self._get_username() password = self._get_password() if username is not None and password is not None: - headers["Authorization"] = httpx.BasicAuth(username, password)._auth_header + headers["Authorization"] = httpx2.BasicAuth(username, password)._auth_header if self._x_langfuse_sdk_name is not None: headers["X-Langfuse-Sdk-Name"] = self._x_langfuse_sdk_name if self._x_langfuse_sdk_version is not None: @@ -79,7 +79,7 @@ def __init__( headers: typing.Optional[typing.Dict[str, str]] = None, base_url: str, timeout: typing.Optional[float] = None, - httpx_client: httpx.Client, + httpx_client: httpx2.Client, ): super().__init__( x_langfuse_sdk_name=x_langfuse_sdk_name, @@ -112,7 +112,7 @@ def __init__( base_url: str, timeout: typing.Optional[float] = None, async_token: typing.Optional[typing.Callable[[], typing.Awaitable[str]]] = None, - httpx_client: httpx.AsyncClient, + httpx_client: httpx2.AsyncClient, ): super().__init__( x_langfuse_sdk_name=x_langfuse_sdk_name, diff --git a/langfuse/api/core/http_client.py b/langfuse/api/core/http_client.py index 3025a49ba..19b22fe90 100644 --- a/langfuse/api/core/http_client.py +++ b/langfuse/api/core/http_client.py @@ -9,21 +9,21 @@ from contextlib import asynccontextmanager, contextmanager from random import random -import httpx +import httpx2 from .file import File, convert_file_dict_to_httpx_tuples from .force_multipart import FORCE_MULTIPART from .jsonable_encoder import jsonable_encoder from .query_encoder import encode_query from .remove_none_from_dict import remove_none_from_dict as remove_none_from_dict from .request_options import RequestOptions -from httpx._types import RequestFiles +from httpx2._types import RequestFiles INITIAL_RETRY_DELAY_SECONDS = 1.0 MAX_RETRY_DELAY_SECONDS = 60.0 JITTER_FACTOR = 0.2 # 20% random jitter -def _parse_retry_after(response_headers: httpx.Headers) -> typing.Optional[float]: +def _parse_retry_after(response_headers: httpx2.Headers) -> typing.Optional[float]: """ This function parses the `Retry-After` header in a HTTP response and returns the number of seconds to wait. @@ -76,7 +76,7 @@ def _add_symmetric_jitter(delay: float) -> float: return delay * jitter_multiplier -def _parse_x_ratelimit_reset(response_headers: httpx.Headers) -> typing.Optional[float]: +def _parse_x_ratelimit_reset(response_headers: httpx2.Headers) -> typing.Optional[float]: """ Parse the X-RateLimit-Reset header (Unix timestamp in seconds). Returns seconds to wait, or None if header is missing/invalid. @@ -96,7 +96,7 @@ def _parse_x_ratelimit_reset(response_headers: httpx.Headers) -> typing.Optional return None -def _retry_timeout(response: httpx.Response, retries: int) -> float: +def _retry_timeout(response: httpx2.Response, retries: int) -> float: """ Determine the amount of time to wait before retrying a request. This function begins by trying to parse a retry-after header from the response, and then proceeds to use exponential backoff @@ -120,7 +120,7 @@ def _retry_timeout(response: httpx.Response, retries: int) -> float: return _add_symmetric_jitter(backoff) -def _should_retry(response: httpx.Response) -> bool: +def _should_retry(response: httpx2.Response) -> bool: retryable_400s = [429, 408, 409] return response.status_code >= 500 or response.status_code in retryable_400s @@ -132,7 +132,7 @@ def _maybe_filter_none_from_multipart_data( ) -> typing.Optional[typing.Any]: """ Filter None values from data body for multipart/form requests. - This prevents httpx from converting None to empty strings in multipart encoding. + This prevents httpx2 from converting None to empty strings in multipart encoding. Only applies when files are present or force_multipart is True. """ if ( @@ -210,7 +210,7 @@ class HttpClient: def __init__( self, *, - httpx_client: httpx.Client, + httpx_client: httpx2.Client, base_timeout: typing.Callable[[], typing.Optional[float]], base_headers: typing.Callable[[], typing.Dict[str, str]], base_url: typing.Optional[typing.Callable[[], str]] = None, @@ -256,7 +256,7 @@ def request( retries: int = 0, omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, - ) -> httpx.Response: + ) -> httpx2.Response: base_url = self.get_base_url(base_url) timeout = ( request_options.get("timeout_in_seconds") @@ -284,8 +284,8 @@ def request( data_body, request_files, force_multipart ) - # Compute encoded params separately to avoid passing empty list to httpx - # (httpx strips existing query params from URL when params=[] is passed) + # Compute encoded params separately to avoid passing empty list to httpx2 + # (httpx2 strips existing query params from URL when params=[] is passed) _encoded_params = encode_query( jsonable_encoder( remove_none_from_dict( @@ -377,7 +377,7 @@ def stream( retries: int = 0, omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, - ) -> typing.Iterator[httpx.Response]: + ) -> typing.Iterator[httpx2.Response]: base_url = self.get_base_url(base_url) timeout = ( request_options.get("timeout_in_seconds") @@ -405,8 +405,8 @@ def stream( data_body, request_files, force_multipart ) - # Compute encoded params separately to avoid passing empty list to httpx - # (httpx strips existing query params from URL when params=[] is passed) + # Compute encoded params separately to avoid passing empty list to httpx2 + # (httpx2 strips existing query params from URL when params=[] is passed) _encoded_params = encode_query( jsonable_encoder( remove_none_from_dict( @@ -455,7 +455,7 @@ class AsyncHttpClient: def __init__( self, *, - httpx_client: httpx.AsyncClient, + httpx_client: httpx2.AsyncClient, base_timeout: typing.Callable[[], typing.Optional[float]], base_headers: typing.Callable[[], typing.Dict[str, str]], base_url: typing.Optional[typing.Callable[[], str]] = None, @@ -510,7 +510,7 @@ async def request( retries: int = 0, omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, - ) -> httpx.Response: + ) -> httpx2.Response: base_url = self.get_base_url(base_url) timeout = ( request_options.get("timeout_in_seconds") @@ -541,8 +541,8 @@ async def request( # Get headers (supports async token providers) _headers = await self._get_headers() - # Compute encoded params separately to avoid passing empty list to httpx - # (httpx strips existing query params from URL when params=[] is passed) + # Compute encoded params separately to avoid passing empty list to httpx2 + # (httpx2 strips existing query params from URL when params=[] is passed) _encoded_params = encode_query( jsonable_encoder( remove_none_from_dict( @@ -634,7 +634,7 @@ async def stream( retries: int = 0, omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, - ) -> typing.AsyncIterator[httpx.Response]: + ) -> typing.AsyncIterator[httpx2.Response]: base_url = self.get_base_url(base_url) timeout = ( request_options.get("timeout_in_seconds") @@ -665,8 +665,8 @@ async def stream( # Get headers (supports async token providers) _headers = await self._get_headers() - # Compute encoded params separately to avoid passing empty list to httpx - # (httpx strips existing query params from URL when params=[] is passed) + # Compute encoded params separately to avoid passing empty list to httpx2 + # (httpx2 strips existing query params from URL when params=[] is passed) _encoded_params = encode_query( jsonable_encoder( remove_none_from_dict( diff --git a/langfuse/api/core/http_response.py b/langfuse/api/core/http_response.py index 2479747e8..b4f4ca3a2 100644 --- a/langfuse/api/core/http_response.py +++ b/langfuse/api/core/http_response.py @@ -2,7 +2,7 @@ from typing import Dict, Generic, TypeVar -import httpx +import httpx2 # Generic to represent the underlying type of the data wrapped by the HTTP response. T = TypeVar("T") @@ -11,9 +11,9 @@ class BaseHttpResponse: """Minimalist HTTP response wrapper that exposes response headers.""" - _response: httpx.Response + _response: httpx2.Response - def __init__(self, response: httpx.Response): + def __init__(self, response: httpx2.Response): self._response = response @property @@ -26,7 +26,7 @@ class HttpResponse(Generic[T], BaseHttpResponse): _data: T - def __init__(self, response: httpx.Response, data: T): + def __init__(self, response: httpx2.Response, data: T): super().__init__(response) self._data = data @@ -43,7 +43,7 @@ class AsyncHttpResponse(Generic[T], BaseHttpResponse): _data: T - def __init__(self, response: httpx.Response, data: T): + def __init__(self, response: httpx2.Response, data: T): super().__init__(response) self._data = data diff --git a/langfuse/api/core/http_sse/_api.py b/langfuse/api/core/http_sse/_api.py index eb739a22b..8a653d71c 100644 --- a/langfuse/api/core/http_sse/_api.py +++ b/langfuse/api/core/http_sse/_api.py @@ -4,14 +4,14 @@ from contextlib import asynccontextmanager, contextmanager from typing import Any, AsyncGenerator, AsyncIterator, Iterator, cast -import httpx +import httpx2 from ._decoders import SSEDecoder from ._exceptions import SSEError from ._models import ServerSentEvent class EventSource: - def __init__(self, response: httpx.Response) -> None: + def __init__(self, response: httpx2.Response) -> None: self._response = response def _check_content_type(self) -> None: @@ -42,7 +42,7 @@ def _get_charset(self) -> str: return "utf-8" @property - def response(self) -> httpx.Response: + def response(self) -> httpx2.Response: return self._response def iter_sse(self) -> Iterator[ServerSentEvent]: @@ -89,7 +89,7 @@ async def aiter_sse(self) -> AsyncGenerator[ServerSentEvent, None]: @contextmanager def connect_sse( - client: httpx.Client, method: str, url: str, **kwargs: Any + client: httpx2.Client, method: str, url: str, **kwargs: Any ) -> Iterator[EventSource]: headers = kwargs.pop("headers", {}) headers["Accept"] = "text/event-stream" @@ -101,7 +101,7 @@ def connect_sse( @asynccontextmanager async def aconnect_sse( - client: httpx.AsyncClient, + client: httpx2.AsyncClient, method: str, url: str, **kwargs: Any, diff --git a/langfuse/api/core/http_sse/_exceptions.py b/langfuse/api/core/http_sse/_exceptions.py index 81605a8a6..f357eb99f 100644 --- a/langfuse/api/core/http_sse/_exceptions.py +++ b/langfuse/api/core/http_sse/_exceptions.py @@ -1,7 +1,7 @@ # This file was auto-generated by Fern from our API Definition. -import httpx +import httpx2 -class SSEError(httpx.TransportError): +class SSEError(httpx2.TransportError): pass diff --git a/langfuse/logger.py b/langfuse/logger.py index afe8c0aef..bcfd461b1 100644 --- a/langfuse/logger.py +++ b/langfuse/logger.py @@ -1,7 +1,7 @@ """Logger configuration for Langfuse OpenTelemetry integration. This module initializes and configures loggers used by the Langfuse OpenTelemetry integration. -It sets up the main 'langfuse' logger and configures the httpx logger to reduce noise. +It sets up the main 'langfuse' logger and configures the httpx2 logger to reduce noise. Log levels used throughout Langfuse: - DEBUG: Detailed tracing information useful for development and diagnostics @@ -17,8 +17,8 @@ langfuse_logger = logging.getLogger("langfuse") langfuse_logger.setLevel(logging.WARNING) -# Configure httpx logger to reduce noise from HTTP requests -httpx_logger = logging.getLogger("httpx") +# Configure httpx2 logger to reduce noise from HTTP requests +httpx_logger = logging.getLogger("httpx2") httpx_logger.setLevel(logging.WARNING) # Add console handler if no handlers exist diff --git a/langfuse/media.py b/langfuse/media.py index 6410079ab..f5cefa94c 100644 --- a/langfuse/media.py +++ b/langfuse/media.py @@ -8,7 +8,7 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, Literal, Optional, Tuple, TypeVar, cast -import httpx +import httpx2 from langfuse.api import MediaContentType from langfuse.logger import langfuse_logger as logger @@ -49,18 +49,18 @@ def is_url_expired(self) -> bool: return expiry_datetime <= datetime.now(timezone.utc) def fetch_bytes( - self, *, timeout: float = 30.0, client: Optional[httpx.Client] = None + self, *, timeout: float = 30.0, client: Optional[httpx2.Client] = None ) -> bytes: """Fetch the media content from the signed URL. Args: timeout: Request timeout in seconds. - client: Optional httpx client to use for the request. Pass this to + client: Optional httpx2 client to use for the request. Pass this to honor custom transport settings (proxy, CA bundle, mTLS) — in particular when multiple Langfuse clients are configured, since the SDK cannot otherwise tell which client produced this reference. When omitted, the single configured client is used, - falling back to a default httpx client. + falling back to a default httpx2 client. """ from langfuse._client.resource_manager import LangfuseResourceManager @@ -68,14 +68,14 @@ def fetch_bytes( response = ( httpx_client.get(self.url, timeout=timeout) if httpx_client is not None - else httpx.get(self.url, timeout=timeout) + else httpx2.get(self.url, timeout=timeout) ) response.raise_for_status() return response.content def fetch_base64( - self, *, timeout: float = 30.0, client: Optional[httpx.Client] = None + self, *, timeout: float = 30.0, client: Optional[httpx2.Client] = None ) -> str: """Fetch media and return raw base64 without a data URI prefix. @@ -86,7 +86,7 @@ def fetch_base64( ).decode() def fetch_data_uri( - self, *, timeout: float = 30.0, client: Optional[httpx.Client] = None + self, *, timeout: float = 30.0, client: Optional[httpx2.Client] = None ) -> str: """Fetch media and return it as a data URI. @@ -383,7 +383,7 @@ def traverse(obj: Any, depth: int) -> Any: timeout=content_fetch_timeout_seconds, ) if httpx_client is not None - else httpx.get( + else httpx2.get( media_data.url, timeout=content_fetch_timeout_seconds ) ) diff --git a/pyproject.toml b/pyproject.toml index 7defd3079..5d852ad4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ license = "MIT" license-files = ["LICENSE"] requires-python = ">=3.10,<4.0" dependencies = [ - "httpx>=0.15.4,<1.0", + "httpx2>=2.12.0", "pydantic>=2,<3", "backoff>=1.10.0", "wrapt>=1.14,<3", diff --git a/tests/support/api_wrapper.py b/tests/support/api_wrapper.py index c4519252f..6b74733b2 100644 --- a/tests/support/api_wrapper.py +++ b/tests/support/api_wrapper.py @@ -1,6 +1,6 @@ import os -import httpx +import httpx2 from langfuse.api.commons.errors.not_found_error import NotFoundError from tests.support.retry import ( @@ -29,7 +29,7 @@ def _get_json( interval_seconds=DEFAULT_RETRY_INTERVAL_SECONDS, ): def _request(): - response = httpx.get(url, params=params, auth=self.auth) + response = httpx2.get(url, params=params, auth=self.auth) payload = response.json() if response.status_code == 404 and is_not_found_payload(payload): diff --git a/tests/unit/test_additional_headers_simple.py b/tests/unit/test_additional_headers_simple.py index dd843b35a..cddd6edb3 100644 --- a/tests/unit/test_additional_headers_simple.py +++ b/tests/unit/test_additional_headers_simple.py @@ -5,7 +5,7 @@ from typing import Sequence -import httpx +import httpx2 from opentelemetry.sdk.trace import ReadableSpan from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult @@ -32,7 +32,7 @@ def teardown_method(self): LangfuseResourceManager.reset() def test_httpx_client_has_additional_headers_when_none_provided(self): - """Test that additional headers are set in httpx client when no custom client is provided.""" + """Test that additional headers are set in httpx2 client when no custom client is provided.""" additional_headers = { "X-Custom-Header": "custom-value", "X-Another-Header": "another-value", @@ -46,7 +46,7 @@ def test_httpx_client_has_additional_headers_when_none_provided(self): tracing_enabled=False, # Disable tracing to avoid OTEL setup ) - # Verify the httpx client has the additional headers + # Verify the httpx2 client has the additional headers assert ( langfuse._resources.httpx_client.headers["X-Custom-Header"] == "custom-value" @@ -60,9 +60,9 @@ def test_custom_httpx_client_with_additional_headers_ignores_additional_headers( self, ): """Test that when additional headers are provided with custom client, additional headers are ignored.""" - # Create a custom httpx client with headers + # Create a custom httpx2 client with headers existing_headers = {"X-Existing-Header": "existing-value"} - custom_client = httpx.Client(headers=existing_headers) + custom_client = httpx2.Client(headers=existing_headers) additional_headers = { "X-Custom-Header": "custom-value", @@ -93,9 +93,9 @@ def test_custom_httpx_client_with_additional_headers_ignores_additional_headers( def test_custom_httpx_client_without_additional_headers_preserves_client(self): """Test that when no additional headers are provided, the custom client is preserved.""" - # Create a custom httpx client with headers + # Create a custom httpx2 client with headers existing_headers = {"X-Existing-Header": "existing-value"} - custom_client = httpx.Client(headers=existing_headers) + custom_client = httpx2.Client(headers=existing_headers) langfuse = Langfuse( public_key="test-public-key", @@ -115,8 +115,8 @@ def test_custom_httpx_client_without_additional_headers_preserves_client(self): ) def test_media_manager_uses_custom_httpx_client(self): - """Test that media manager reuses the configured custom httpx client.""" - custom_client = httpx.Client() + """Test that media manager reuses the configured custom httpx2 client.""" + custom_client = httpx2.Client() langfuse = Langfuse( public_key="test-public-key", diff --git a/tests/unit/test_e2e_support.py b/tests/unit/test_e2e_support.py index 8320bd2fe..df344e055 100644 --- a/tests/unit/test_e2e_support.py +++ b/tests/unit/test_e2e_support.py @@ -108,7 +108,7 @@ def fake_get(*args, **kwargs): return FakeResponse(200, {"id": "trace-123", "observations": []}) - monkeypatch.setattr("tests.support.api_wrapper.httpx.get", fake_get) + monkeypatch.setattr("tests.support.api_wrapper.httpx2.get", fake_get) api = SupportLangfuseAPI(username="user", password="pass", base_url="http://test") trace = api.get_trace("trace-123") diff --git a/tests/unit/test_media.py b/tests/unit/test_media.py index 387eae745..bedf1378c 100644 --- a/tests/unit/test_media.py +++ b/tests/unit/test_media.py @@ -138,7 +138,7 @@ def test_media_reference_fetch_uses_configured_httpx_client(monkeypatch): configured_httpx_client = Mock() configured_httpx_client.get.return_value = response httpx_get = Mock() - monkeypatch.setattr("langfuse.media.httpx.get", httpx_get) + monkeypatch.setattr("langfuse.media.httpx2.get", httpx_get) monkeypatch.setattr( LangfuseResourceManager, "_instances", @@ -167,7 +167,7 @@ def test_media_reference_fetch_uses_explicit_client(monkeypatch): singleton_client = Mock() httpx_get = Mock() - monkeypatch.setattr("langfuse.media.httpx.get", httpx_get) + monkeypatch.setattr("langfuse.media.httpx2.get", httpx_get) monkeypatch.setattr( LangfuseResourceManager, "_instances", @@ -186,7 +186,7 @@ def test_media_reference_fetch_uses_explicit_client(monkeypatch): explicit_client.get.assert_called_once_with( "https://example.com/test.jpg", timeout=5.0 ) - # Explicit client wins over the configured singleton and the default httpx. + # Explicit client wins over the configured singleton and the default httpx2. singleton_client.get.assert_not_called() httpx_get.assert_not_called() @@ -200,7 +200,7 @@ def test_media_reference_fetch_falls_back_to_default_with_multiple_clients( response.content = b"default-bytes" response.raise_for_status.return_value = None httpx_get = Mock(return_value=response) - monkeypatch.setattr("langfuse.media.httpx.get", httpx_get) + monkeypatch.setattr("langfuse.media.httpx2.get", httpx_get) client_a = Mock() client_b = Mock() @@ -222,7 +222,7 @@ def test_media_reference_fetch_falls_back_to_default_with_multiple_clients( with caplog.at_level(logging.WARNING, logger="langfuse"): assert reference.fetch_bytes(timeout=8.0) == b"default-bytes" - # Ambiguous multi-client setup: warn and fall back to the default httpx + # Ambiguous multi-client setup: warn and fall back to the default httpx2 # instead of silently using an arbitrary instance's transport config. assert "Multiple Langfuse clients" in caplog.text httpx_get.assert_called_once_with("https://example.com/test.jpg", timeout=8.0) diff --git a/tests/unit/test_media_manager.py b/tests/unit/test_media_manager.py index 3ab4e3226..4eb32ccb2 100644 --- a/tests/unit/test_media_manager.py +++ b/tests/unit/test_media_manager.py @@ -2,16 +2,16 @@ from types import SimpleNamespace from unittest.mock import Mock -import httpx +import httpx2 import pytest from langfuse._task_manager.media_manager import MediaManager from langfuse.media import LangfuseMedia -def _upload_response(status_code: int, text: str = "") -> httpx.Response: - request = httpx.Request("PUT", "https://example.com/upload") - return httpx.Response(status_code=status_code, request=request, text=text) +def _upload_response(status_code: int, text: str = "") -> httpx2.Response: + request = httpx2.Request("PUT", "https://example.com/upload") + return httpx2.Response(status_code=status_code, request=request, text=text) def _upload_job() -> dict: @@ -75,7 +75,7 @@ def test_media_upload_gives_up_on_non_retryable_http_status(): max_retries=3, ) - with pytest.raises(httpx.HTTPStatusError): + with pytest.raises(httpx2.HTTPStatusError): manager._process_upload_media_job(data=_upload_job()) assert httpx_client.put.call_count == 1 diff --git a/tests/unit/test_openai.py b/tests/unit/test_openai.py index 681be4fbf..eac017e58 100644 --- a/tests/unit/test_openai.py +++ b/tests/unit/test_openai.py @@ -1272,27 +1272,27 @@ def _chat_completion_chunk_sse_body(): def _mock_transport_openai_client(async_client: bool = False): - import httpx + import httpx2 - def handler(request: httpx.Request) -> httpx.Response: + def handler(request: httpx2.Request) -> httpx2.Response: if b'"stream": true' in request.content or b'"stream":true' in request.content: - return httpx.Response( + return httpx2.Response( 200, content=_chat_completion_chunk_sse_body().encode(), headers={"content-type": "text/event-stream"}, ) - return httpx.Response(200, json=_chat_completion_payload()) + return httpx2.Response(200, json=_chat_completion_payload()) if async_client: return lf_openai.AsyncOpenAI( api_key="test", - http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), ) return lf_openai.OpenAI( api_key="test", - http_client=httpx.Client(transport=httpx.MockTransport(handler)), + http_client=httpx2.Client(transport=httpx2.MockTransport(handler)), ) diff --git a/tests/unit/test_resource_manager.py b/tests/unit/test_resource_manager.py index f66a1e052..882af5de0 100644 --- a/tests/unit/test_resource_manager.py +++ b/tests/unit/test_resource_manager.py @@ -311,17 +311,17 @@ def test_at_fork_reinit_new_lock_acquirable_even_if_old_lock_was_held(monkeypatc def test_at_fork_reinit_recreates_httpx_client_by_default(monkeypatch): - """_at_fork_reinit() must create a new httpx.Client to avoid sharing + """_at_fork_reinit() must create a new httpx2.Client to avoid sharing connection-pool file descriptors (TCP sockets) across forked processes. - httpx.Client is thread-safe but not process-safe.""" + httpx2.Client is thread-safe but not process-safe.""" monkeypatch.setenv("LANGFUSE_MEDIA_UPLOAD_ENABLED", "false") with LangfuseResourceManager._lock: LangfuseResourceManager._instances.clear() client = Langfuse( - public_key="pk-fork-httpx-default", - secret_key="sk-fork-httpx-default", + public_key="pk-fork-httpx2-default", + secret_key="sk-fork-httpx2-default", span_exporter=NoOpSpanExporter(), ) rm = client._resources @@ -349,20 +349,20 @@ def test_at_fork_reinit_recreates_httpx_client_by_default(monkeypatch): def test_at_fork_reinit_preserves_custom_httpx_client(monkeypatch): - """After fork, a caller-supplied httpx.Client is reused as-is. + """After fork, a caller-supplied httpx2.Client is reused as-is. The caller is responsible for their own fork-safety (e.g. via their own os.register_at_fork handler). The SDK must not silently replace it.""" - import httpx + import httpx2 monkeypatch.setenv("LANGFUSE_MEDIA_UPLOAD_ENABLED", "false") with LangfuseResourceManager._lock: LangfuseResourceManager._instances.clear() - custom_client = httpx.Client(timeout=99) + custom_client = httpx2.Client(timeout=99) client = Langfuse( - public_key="pk-fork-httpx-custom", - secret_key="sk-fork-httpx-custom", + public_key="pk-fork-httpx2-custom", + secret_key="sk-fork-httpx2-custom", httpx_client=custom_client, span_exporter=NoOpSpanExporter(), ) @@ -385,7 +385,7 @@ def test_at_fork_reinit_preserves_custom_httpx_client(monkeypatch): def test_at_fork_reinit_new_httpx_client_uses_configured_timeout_and_headers( monkeypatch, ): - """After fork, the recreated httpx.Client must reflect the timeout and + """After fork, the recreated httpx2.Client must reflect the timeout and additional_headers that were set on the resource manager.""" monkeypatch.setenv("LANGFUSE_MEDIA_UPLOAD_ENABLED", "false") @@ -393,8 +393,8 @@ def test_at_fork_reinit_new_httpx_client_uses_configured_timeout_and_headers( LangfuseResourceManager._instances.clear() client = Langfuse( - public_key="pk-fork-httpx-settings", - secret_key="sk-fork-httpx-settings", + public_key="pk-fork-httpx2-settings", + secret_key="sk-fork-httpx2-settings", timeout=42, additional_headers={"X-Custom": "value"}, span_exporter=NoOpSpanExporter(),