Skip to content

Commit f92ae14

Browse files
authored
build: always use httpx instead of requests; improve HTTP errors (#11047)
* use httpx * more clean up * relnote * add upgrade note
1 parent c5f5181 commit f92ae14

13 files changed

Lines changed: 137 additions & 121 deletions

File tree

haystack/components/connectors/openapi_service.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ class OpenAPIServiceConnector:
174174
<!-- test-ignore -->
175175
```python
176176
import json
177-
import requests
177+
import httpx
178178
179179
from haystack.components.connectors import OpenAPIServiceConnector
180180
from haystack.dataclasses import ChatMessage, ToolCall
@@ -187,7 +187,7 @@ class OpenAPIServiceConnector:
187187
message = ChatMessage.from_assistant(tool_calls=[tool_call])
188188
189189
serper_token = Secret.from_env_var("SERPERDEV_API_KEY").resolve_value()
190-
serperdev_openapi_spec = json.loads(requests.get("https://bit.ly/serper_dev_spec").text)
190+
serperdev_openapi_spec = json.loads(httpx.get("https://bit.ly/serper_dev_spec", follow_redirects=True).text)
191191
service_connector = OpenAPIServiceConnector()
192192
result = service_connector.run(
193193
messages=[message],

haystack/components/rankers/hugging_face_tei.py

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from typing import Any
88
from urllib.parse import urljoin
99

10+
import httpx
11+
1012
from haystack import Document, component, default_from_dict, default_to_dict
1113
from haystack.utils import Secret
1214
from haystack.utils.misc import _deduplicate_documents
@@ -133,7 +135,7 @@ def _compose_response(
133135
:returns: A dictionary with the following keys:
134136
- `documents`: A list of reranked documents.
135137
136-
:raises requests.exceptions.RequestException:
138+
:raises RuntimeError:
137139
- If the API request fails.
138140
139141
:raises RuntimeError:
@@ -186,7 +188,7 @@ def run(
186188
:returns: A dictionary with the following keys:
187189
- `documents`: A list of reranked documents.
188190
189-
:raises requests.exceptions.RequestException:
191+
:raises RuntimeError:
190192
- If the API request fails.
191193
192194
:raises RuntimeError:
@@ -211,15 +213,18 @@ def run(
211213
headers["Authorization"] = f"Bearer {self.token.resolve_value()}"
212214

213215
# Call the external service with retry
214-
response = request_with_retry(
215-
method="POST",
216-
url=urljoin(self.url, "/rerank"),
217-
json=payload,
218-
timeout=self.timeout,
219-
headers=headers,
220-
attempts=self.max_retries,
221-
status_codes_to_retry=self.retry_status_codes,
222-
)
216+
try:
217+
response = request_with_retry(
218+
method="POST",
219+
url=urljoin(self.url, "/rerank"),
220+
json=payload,
221+
timeout=self.timeout,
222+
headers=headers,
223+
attempts=self.max_retries,
224+
status_codes_to_retry=self.retry_status_codes,
225+
)
226+
except httpx.HTTPStatusError as e:
227+
raise RuntimeError(f"HuggingFaceTEIRanker API call failed. Error: {e}, Response: {e.response.text}") from e
223228

224229
result: dict[str, str] | list[dict[str, Any]] = response.json()
225230

@@ -270,15 +275,18 @@ async def run_async(
270275
headers["Authorization"] = f"Bearer {self.token.resolve_value()}"
271276

272277
# Call the external service with retry
273-
response = await async_request_with_retry(
274-
method="POST",
275-
url=urljoin(self.url, "/rerank"),
276-
json=payload,
277-
timeout=self.timeout,
278-
headers=headers,
279-
attempts=self.max_retries,
280-
status_codes_to_retry=self.retry_status_codes,
281-
)
278+
try:
279+
response = await async_request_with_retry(
280+
method="POST",
281+
url=urljoin(self.url, "/rerank"),
282+
json=payload,
283+
timeout=self.timeout,
284+
headers=headers,
285+
attempts=self.max_retries,
286+
status_codes_to_retry=self.retry_status_codes,
287+
)
288+
except httpx.HTTPStatusError as e:
289+
raise RuntimeError(f"HuggingFaceTEIRanker API call failed. Error: {e}, Response: {e.response.text}") from e
282290

283291
result: dict[str, str] | list[dict[str, Any]] = response.json()
284292

haystack/components/websearch/searchapi.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@ def run(self, query: str) -> dict[str, list[Document] | list[str]]:
114114
except httpx.ConnectTimeout as error:
115115
raise TimeoutError(f"Request to {self.__class__.__name__} timed out.") from error
116116

117+
except httpx.HTTPStatusError as e:
118+
raise SearchApiError(
119+
f"An error occurred while querying {self.__class__.__name__}. Error: {e}, Response: {e.response.text}"
120+
) from e
121+
117122
except httpx.HTTPError as e:
118123
raise SearchApiError(f"An error occurred while querying {self.__class__.__name__}. Error: {e}") from e
119124

@@ -149,6 +154,11 @@ async def run_async(self, query: str) -> dict[str, list[Document] | list[str]]:
149154
except httpx.ConnectTimeout as error:
150155
raise TimeoutError(f"Request to {self.__class__.__name__} timed out.") from error
151156

157+
except httpx.HTTPStatusError as e:
158+
raise SearchApiError(
159+
f"An error occurred while querying {self.__class__.__name__}. Error: {e}, Response: {e.response.text}"
160+
) from e
161+
152162
except httpx.HTTPError as e:
153163
raise SearchApiError(f"An error occurred while querying {self.__class__.__name__}. Error: {e}") from e
154164

haystack/components/websearch/serper_dev.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,11 @@ def run(self, query: str) -> dict[str, list[Document] | list[str]]:
159159
except httpx.ConnectTimeout as error:
160160
raise TimeoutError(f"Request to {self.__class__.__name__} timed out.") from error
161161

162+
except httpx.HTTPStatusError as e:
163+
raise SerperDevError(
164+
f"An error occurred while querying {self.__class__.__name__}. Error: {e}, Response: {e.response.text}"
165+
) from e
166+
162167
except httpx.HTTPError as e:
163168
raise SerperDevError(f"An error occurred while querying {self.__class__.__name__}. Error: {e}") from e
164169

@@ -194,6 +199,11 @@ async def run_async(self, query: str) -> dict[str, list[Document] | list[str]]:
194199
except httpx.ConnectTimeout as error:
195200
raise TimeoutError(f"Request to {self.__class__.__name__} timed out.") from error
196201

202+
except httpx.HTTPStatusError as e:
203+
raise SerperDevError(
204+
f"An error occurred while querying {self.__class__.__name__}. Error: {e}, Response: {e.response.text}"
205+
) from e
206+
197207
except httpx.HTTPError as e:
198208
raise SerperDevError(f"An error occurred while querying {self.__class__.__name__}. Error: {e}") from e
199209

haystack/core/pipeline/draw.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
import zlib
1010
from typing import Any
1111

12+
import httpx
1213
import networkx
13-
import requests
1414

1515
from haystack import logging
1616
from haystack.core.errors import PipelineDrawingError
@@ -234,7 +234,7 @@ def _to_mermaid_image(
234234

235235
logger.debug("Rendering graph at {url}", url=url)
236236
try:
237-
resp = requests.get(url, timeout=timeout)
237+
resp = httpx.get(url, timeout=timeout)
238238
if resp.status_code >= 400:
239239
logger.warning(
240240
"Failed to draw the pipeline: {server_url} returned status {status_code}",

haystack/utils/requests_utils.py

Lines changed: 14 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,14 @@
66
from typing import Any
77

88
import httpx
9-
import requests
109
from tenacity import after_log, before_log, retry, retry_if_exception_type, stop_after_attempt, wait_exponential
1110

1211
logger = logging.getLogger(__file__)
1312

1413

1514
def request_with_retry(
1615
attempts: int = 3, status_codes_to_retry: list[int] | None = None, **kwargs: Any
17-
) -> requests.Response:
16+
) -> httpx.Response:
1817
"""
1918
Executes an HTTP request with a configurable exponential backoff retry on failures.
2019
@@ -35,26 +34,11 @@ def request_with_retry(
3534
# Sending an HTTP request with custom timeout in seconds
3635
res = request_with_retry(method="GET", url="https://example.com", timeout=5)
3736
38-
# Sending an HTTP request with custom authorization handling
39-
class CustomAuth(requests.auth.AuthBase):
40-
def __call__(self, r):
41-
r.headers["authorization"] = "Basic <my_token_here>"
42-
return r
43-
44-
res = request_with_retry(method="GET", url="https://example.com", auth=CustomAuth())
45-
46-
# All of the above combined
47-
res = request_with_retry(
48-
method="GET",
49-
url="https://example.com",
50-
auth=CustomAuth(),
51-
attempts=10,
52-
status_codes_to_retry=[408, 503],
53-
timeout=5
54-
)
37+
# Sending an HTTP request with custom headers
38+
res = request_with_retry(method="GET", url="https://example.com", headers={"Authorization": "Bearer <token>"})
5539
5640
# Sending a POST request
57-
res = request_with_retry(method="POST", url="https://example.com", data={"key": "value"}, attempts=10)
41+
res = request_with_retry(method="POST", url="https://example.com", json={"key": "value"}, attempts=10)
5842
5943
# Retry all 5xx status codes
6044
res = request_with_retry(method="GET", url="https://example.com", status_codes_to_retry=list(range(500, 600)))
@@ -66,9 +50,9 @@ def __call__(self, r):
6650
List of HTTP status codes that will trigger a retry.
6751
When param is `None`, HTTP 408, 418, 429 and 503 will be retried.
6852
:param kwargs:
69-
Optional arguments that `request` accepts.
53+
Optional arguments that `httpx.Client.request` accepts.
7054
:returns:
71-
The `Response` object.
55+
The `httpx.Response` object.
7256
"""
7357

7458
if status_codes_to_retry is None:
@@ -77,20 +61,21 @@ def __call__(self, r):
7761
@retry(
7862
reraise=True,
7963
wait=wait_exponential(),
80-
retry=retry_if_exception_type((requests.HTTPError, TimeoutError)),
64+
retry=retry_if_exception_type((httpx.HTTPError, TimeoutError)),
8165
stop=stop_after_attempt(attempts),
8266
before=before_log(logger, logging.DEBUG),
8367
after=after_log(logger, logging.DEBUG),
8468
)
85-
def run() -> requests.Response:
69+
def run() -> httpx.Response:
8670
timeout = kwargs.pop("timeout", 10)
87-
res = requests.request(**kwargs, timeout=timeout)
71+
with httpx.Client() as client:
72+
res = client.request(**kwargs, timeout=timeout)
8873

89-
if res.status_code in status_codes_to_retry:
90-
# We raise only for the status codes that must trigger a retry
91-
res.raise_for_status()
74+
if res.status_code in status_codes_to_retry:
75+
# We raise only for the status codes that must trigger a retry
76+
res.raise_for_status()
9277

93-
return res
78+
return res
9479

9580
res = run()
9681
# We raise here too in case the request failed with a status code that

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ dependencies = [
5454
"more-itertools", # TextDocumentSplitter
5555
"networkx", # Pipeline graphs
5656
"typing_extensions>=4.7", # Extended typing features (NotRequired, etc.)
57-
"requests",
57+
"httpx",
5858
"numpy",
5959
"python-dateutil",
6060
"jsonschema", # JsonSchemaValidator, Tool
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
enhancements:
3+
- |
4+
Standardize HTTP request handling in Haystack by adopting ``httpx`` for both synchronous and asynchronous requests,
5+
replacing ``requests``. Error reporting for failed requests has also been improved: exceptions now include
6+
additional details alongside the reason field.
7+
upgrade:
8+
- |
9+
As part of the migration from ``requests`` to ``httpx``, ``request_with_retry`` and ``async_request_with_retry``
10+
(in ``haystack.utils.requests_utils``) no longer raise ``requests.exceptions.RequestException`` on failure;
11+
they now raise ``httpx.HTTPError`` instead. This also affects ``HuggingFaceTEIRanker``, which relies on these
12+
utilities. Users catching ``requests.exceptions.RequestException`` should update their code to catch
13+
``httpx.HTTPError``.

test/components/connectors/test_openapi_service.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
from typing import Any
88
from unittest.mock import MagicMock, Mock, patch
99

10+
import httpx
1011
import pytest
11-
import requests
1212
from openapi3 import OpenAPI
1313

1414
from haystack import Pipeline
@@ -266,10 +266,11 @@ def prepare_fc_params(openai_functions_schema: dict[str, Any]) -> dict[str, Any]
266266
pipe.connect("openapi_container.service_response", "final_prompt_adapter.service_response")
267267
pipe.connect("final_prompt_adapter", "llm.messages")
268268

269-
serperdev_spec = requests.get(
270-
"https://gist.githubusercontent.com/vblagoje/241a000f2a77c76be6efba71d49e2856/raw/722ccc7fe6170a744afce3e3fb3a30fdd095c184/serper.json"
269+
serperdev_spec = httpx.get(
270+
"https://gist.githubusercontent.com/vblagoje/241a000f2a77c76be6efba71d49e2856/raw/722ccc7fe6170a744afce3e3fb3a30fdd095c184/serper.json",
271+
follow_redirects=True,
271272
).json()
272-
system_prompt = requests.get("https://bit.ly/serper_dev_system").text
273+
system_prompt = httpx.get("https://bit.ly/serper_dev_system", follow_redirects=True).text
273274

274275
query = "Why did Elon Musk sue OpenAI?"
275276

test/components/rankers/test_hugging_face_tei.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
import httpx
88
import pytest
9-
import requests
109

1110
from haystack import Document
1211
from haystack.components.rankers.hugging_face_tei import HuggingFaceTEIRanker, TruncationDirection
@@ -93,7 +92,7 @@ def test_empty_documents(self, del_hf_env_vars):
9392
def test_run_with_mock(self, mock_request, del_hf_env_vars):
9493
"""Test run method with mocked API response"""
9594
# Setup mock response
96-
mock_response = MagicMock(spec=requests.Response)
95+
mock_response = MagicMock(spec=httpx.Response)
9796
mock_response.json.return_value = [
9897
{"index": 2, "score": 0.95},
9998
{"index": 1, "score": 0.85},
@@ -141,7 +140,7 @@ def test_run_with_mock(self, mock_request, del_hf_env_vars):
141140
def test_run_with_truncation_direction(self, mock_request, del_hf_env_vars):
142141
"""Test run method with truncation direction parameter"""
143142
# Setup mock response
144-
mock_response = MagicMock(spec=requests.Response)
143+
mock_response = MagicMock(spec=httpx.Response)
145144
mock_response.json.return_value = [{"index": 0, "score": 0.95}]
146145
mock_request.return_value = mock_response
147146

@@ -174,7 +173,7 @@ def test_run_with_truncation_direction(self, mock_request, del_hf_env_vars):
174173
def test_run_with_custom_top_k(self, mock_request, del_hf_env_vars):
175174
"""Test run method with custom top_k parameter"""
176175
# Setup mock response with 5 documents
177-
mock_response = MagicMock(spec=requests.Response)
176+
mock_response = MagicMock(spec=httpx.Response)
178177
mock_response.json.return_value = [
179178
{"index": 4, "score": 0.95},
180179
{"index": 3, "score": 0.90},
@@ -210,7 +209,7 @@ def test_run_with_custom_top_k(self, mock_request, del_hf_env_vars):
210209
@patch("haystack.components.rankers.hugging_face_tei.request_with_retry")
211210
def test_run_deduplicates_documents(self, mock_request, del_hf_env_vars):
212211
"""Test that duplicate documents are removed before sending to the API."""
213-
mock_response = MagicMock(spec=requests.Response)
212+
mock_response = MagicMock(spec=httpx.Response)
214213
mock_response.json.return_value = [{"index": 1, "score": 0.9}, {"index": 0, "score": 0.2}]
215214
mock_request.return_value = mock_response
216215

@@ -241,7 +240,7 @@ def test_run_deduplicates_documents(self, mock_request, del_hf_env_vars):
241240
def test_error_handling(self, mock_request, del_hf_env_vars):
242241
"""Test error handling in the ranker"""
243242
# Setup mock response with error
244-
mock_response = MagicMock(spec=requests.Response)
243+
mock_response = MagicMock(spec=httpx.Response)
245244
mock_response.json.return_value = {"error": "Some error occurred", "error_type": "TestError"}
246245
mock_request.return_value = mock_response
247246

0 commit comments

Comments
 (0)