Skip to content

Commit 12202e8

Browse files
committed
refactor!: migrate HTTP client from requests to httpx
Replace the synchronous HTTP client library from `requests` to `httpx` for improved async support potential and modern API design. Changes include: - Replace `requests>=2.32.3` with `httpx>=0.28.0` in dependencies - Update `stackone_ai/models.py` to use httpx.request() instead of requests.request() - Refactor exception handling to use httpx-specific exceptions: - httpx.HTTPStatusError for HTTP error responses - httpx.RequestError for connection/network errors - Replace test mocking library from `responses` to `respx` - Remove `types-requests` type stubs (no longer needed) - Update all test files to use respx decorators and httpx.Response BREAKING CHANGE: Error handling now uses httpx exceptions instead of requests exceptions. Code catching RequestException should be updated to catch httpx.HTTPStatusError or httpx.RequestError.
1 parent e0a67c2 commit 12202e8

File tree

7 files changed

+186
-212
lines changed

7 files changed

+186
-212
lines changed

pyproject.toml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ classifiers = [
2121
]
2222
dependencies = [
2323
"pydantic>=2.10.6",
24-
"requests>=2.32.3",
24+
"httpx>=0.28.0",
2525
"langchain-core>=0.1.0",
2626
"bm25s>=0.2.2",
2727
"numpy>=1.24.0",
@@ -62,10 +62,9 @@ dev = [
6262
"pytest-asyncio>=0.25.3",
6363
"pytest-cov>=6.0.0",
6464
"pytest-snapshot>=0.9.0",
65-
"responses>=0.25.8",
65+
"respx>=0.22.0",
6666
"ruff>=0.9.6",
6767
"stackone-ai",
68-
"types-requests>=2.31.0.20240311",
6968
]
7069

7170
[tool.pytest.ini_options]

stackone_ai/models.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,9 @@
1010
from typing import Annotated, Any, ClassVar, cast
1111
from urllib.parse import quote
1212

13-
import requests
13+
import httpx
1414
from langchain_core.tools import BaseTool
1515
from pydantic import BaseModel, BeforeValidator, Field, PrivateAttr
16-
from requests.exceptions import RequestException
1716

1817
# TODO: Remove when Python 3.9 support is dropped
1918
from typing_extensions import TypeAlias
@@ -242,7 +241,7 @@ def execute(
242241
if query_params:
243242
request_kwargs["params"] = query_params
244243

245-
response = requests.request(**request_kwargs)
244+
response = httpx.request(**request_kwargs)
246245
response_status = response.status_code
247246
response.raise_for_status()
248247

@@ -254,15 +253,23 @@ def execute(
254253
status = "error"
255254
error_message = f"Invalid JSON in arguments: {exc}"
256255
raise ValueError(error_message) from exc
257-
except RequestException as exc:
256+
except httpx.HTTPStatusError as exc:
257+
status = "error"
258+
error_message = str(exc)
259+
response_body = None
260+
if exc.response.text:
261+
try:
262+
response_body = exc.response.json()
263+
except json.JSONDecodeError:
264+
response_body = exc.response.text
265+
raise StackOneAPIError(
266+
str(exc),
267+
exc.response.status_code,
268+
response_body,
269+
) from exc
270+
except httpx.RequestError as exc:
258271
status = "error"
259272
error_message = str(exc)
260-
if hasattr(exc, "response") and exc.response is not None:
261-
raise StackOneAPIError(
262-
str(exc),
263-
exc.response.status_code,
264-
exc.response.json() if exc.response.text else None,
265-
) from exc
266273
raise StackOneError(f"Request failed: {exc}") from exc
267274
finally:
268275
datetime.now(timezone.utc)

tests/test_feedback.py

Lines changed: 105 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55

66
import json
77
import os
8-
from unittest.mock import Mock, patch
98

9+
import httpx
1010
import pytest
11+
import respx
1112

1213
from stackone_ai.feedback import create_feedback_tool
1314
from stackone_ai.models import StackOneError
@@ -55,165 +56,150 @@ def test_multiple_account_ids_validation(self) -> None:
5556
with pytest.raises(StackOneError, match="At least one valid account ID is required"):
5657
tool.execute({"feedback": "Great tools!", "account_id": ["", " "], "tool_names": ["test_tool"]})
5758

59+
@respx.mock
5860
def test_json_string_input(self) -> None:
5961
"""Test that JSON string input is properly parsed."""
6062
tool = create_feedback_tool(api_key="test_key")
6163

62-
with patch("requests.request") as mock_request:
63-
mock_response = Mock()
64-
mock_response.status_code = 200
65-
mock_response.json.return_value = {"message": "Success"}
66-
mock_response.raise_for_status = Mock()
67-
mock_request.return_value = mock_response
64+
route = respx.post("https://api.stackone.com/ai/tool-feedback").mock(
65+
return_value=httpx.Response(200, json={"message": "Success"})
66+
)
6867

69-
json_string = json.dumps(
70-
{"feedback": "Great tools!", "account_id": "acc_123456", "tool_names": ["test_tool"]}
71-
)
72-
result = tool.execute(json_string)
73-
assert result["message"] == "Success"
68+
json_string = json.dumps(
69+
{"feedback": "Great tools!", "account_id": "acc_123456", "tool_names": ["test_tool"]}
70+
)
71+
result = tool.execute(json_string)
72+
assert result["message"] == "Success"
73+
assert route.called
7474

7575

7676
class TestFeedbackToolExecution:
7777
"""Test suite for feedback tool execution."""
7878

79+
@respx.mock
7980
def test_single_account_execution(self) -> None:
8081
"""Test execution with single account ID."""
8182
tool = create_feedback_tool(api_key="test_key")
8283
api_response = {"message": "Feedback successfully stored", "trace_id": "test-trace-id"}
8384

84-
with patch("requests.request") as mock_request:
85-
mock_response = Mock()
86-
mock_response.status_code = 200
87-
mock_response.json.return_value = api_response
88-
mock_response.raise_for_status = Mock()
89-
mock_request.return_value = mock_response
90-
91-
result = tool.execute(
92-
{
93-
"feedback": "Great tools!",
94-
"account_id": "acc_123456",
95-
"tool_names": ["data_export", "analytics"],
96-
}
97-
)
98-
99-
assert result == api_response
100-
mock_request.assert_called_once()
101-
call_kwargs = mock_request.call_args[1]
102-
assert call_kwargs["method"] == "POST"
103-
assert call_kwargs["url"] == "https://api.stackone.com/ai/tool-feedback"
104-
assert call_kwargs["json"]["feedback"] == "Great tools!"
105-
assert call_kwargs["json"]["account_id"] == "acc_123456"
106-
assert call_kwargs["json"]["tool_names"] == ["data_export", "analytics"]
107-
85+
route = respx.post("https://api.stackone.com/ai/tool-feedback").mock(
86+
return_value=httpx.Response(200, json=api_response)
87+
)
88+
89+
result = tool.execute(
90+
{
91+
"feedback": "Great tools!",
92+
"account_id": "acc_123456",
93+
"tool_names": ["data_export", "analytics"],
94+
}
95+
)
96+
97+
assert result == api_response
98+
assert route.called
99+
assert route.call_count == 1
100+
request = route.calls[0].request
101+
body = json.loads(request.content)
102+
assert body["feedback"] == "Great tools!"
103+
assert body["account_id"] == "acc_123456"
104+
assert body["tool_names"] == ["data_export", "analytics"]
105+
106+
@respx.mock
108107
def test_call_method_interface(self) -> None:
109108
"""Test that the .call() method works correctly."""
110109
tool = create_feedback_tool(api_key="test_key")
111110
api_response = {"message": "Success", "trace_id": "test-trace-id"}
112111

113-
with patch("requests.request") as mock_request:
114-
mock_response = Mock()
115-
mock_response.status_code = 200
116-
mock_response.json.return_value = api_response
117-
mock_response.raise_for_status = Mock()
118-
mock_request.return_value = mock_response
119-
120-
result = tool.call(
121-
feedback="Testing the .call() method interface.",
122-
account_id="acc_test004",
123-
tool_names=["meta_collect_tool_feedback"],
124-
)
112+
route = respx.post("https://api.stackone.com/ai/tool-feedback").mock(
113+
return_value=httpx.Response(200, json=api_response)
114+
)
125115

126-
assert result == api_response
127-
mock_request.assert_called_once()
116+
result = tool.call(
117+
feedback="Testing the .call() method interface.",
118+
account_id="acc_test004",
119+
tool_names=["meta_collect_tool_feedback"],
120+
)
128121

122+
assert result == api_response
123+
assert route.called
124+
assert route.call_count == 1
125+
126+
@respx.mock
129127
def test_api_error_handling(self) -> None:
130128
"""Test that API errors are handled properly."""
131129
tool = create_feedback_tool(api_key="test_key")
132130

133-
with patch("requests.request") as mock_request:
134-
mock_response = Mock()
135-
mock_response.status_code = 401
136-
mock_response.text = '{"error": "Unauthorized"}'
137-
mock_response.json.return_value = {"error": "Unauthorized"}
138-
mock_response.raise_for_status.side_effect = Exception("401 Client Error: Unauthorized")
139-
mock_request.return_value = mock_response
140-
141-
with pytest.raises(StackOneError):
142-
tool.execute(
143-
{
144-
"feedback": "Great tools!",
145-
"account_id": "acc_123456",
146-
"tool_names": ["test_tool"],
147-
}
148-
)
149-
150-
def test_multiple_account_ids_execution(self) -> None:
151-
"""Test execution with multiple account IDs - both success and mixed scenarios."""
152-
tool = create_feedback_tool(api_key="test_key")
153-
api_response = {"message": "Feedback successfully stored", "trace_id": "test-trace-id"}
131+
respx.post("https://api.stackone.com/ai/tool-feedback").mock(
132+
return_value=httpx.Response(401, json={"error": "Unauthorized"})
133+
)
154134

155-
# Test all successful case
156-
with patch("requests.request") as mock_request:
157-
mock_response = Mock()
158-
mock_response.status_code = 200
159-
mock_response.json.return_value = api_response
160-
mock_response.raise_for_status = Mock()
161-
mock_request.return_value = mock_response
162-
163-
result = tool.execute(
135+
with pytest.raises(StackOneError):
136+
tool.execute(
164137
{
165138
"feedback": "Great tools!",
166-
"account_id": ["acc_123456", "acc_789012", "acc_345678"],
139+
"account_id": "acc_123456",
167140
"tool_names": ["test_tool"],
168141
}
169142
)
170143

171-
assert result["message"] == "Feedback sent to 3 account(s)"
172-
assert result["total_accounts"] == 3
173-
assert result["successful"] == 3
174-
assert result["failed"] == 0
175-
assert len(result["results"]) == 3
176-
assert mock_request.call_count == 3
144+
@respx.mock
145+
def test_multiple_account_ids_execution(self) -> None:
146+
"""Test execution with multiple account IDs - both success and mixed scenarios."""
147+
tool = create_feedback_tool(api_key="test_key")
148+
api_response = {"message": "Feedback successfully stored", "trace_id": "test-trace-id"}
177149

178-
# Test mixed success/error case
179-
def mock_request_side_effect(*args, **kwargs):
180-
account_id = kwargs.get("json", {}).get("account_id")
150+
# Test all successful case
151+
route = respx.post("https://api.stackone.com/ai/tool-feedback").mock(
152+
return_value=httpx.Response(200, json=api_response)
153+
)
154+
155+
result = tool.execute(
156+
{
157+
"feedback": "Great tools!",
158+
"account_id": ["acc_123456", "acc_789012", "acc_345678"],
159+
"tool_names": ["test_tool"],
160+
}
161+
)
162+
163+
assert result["message"] == "Feedback sent to 3 account(s)"
164+
assert result["total_accounts"] == 3
165+
assert result["successful"] == 3
166+
assert result["failed"] == 0
167+
assert len(result["results"]) == 3
168+
assert route.call_count == 3
169+
170+
@respx.mock
171+
def test_multiple_account_ids_mixed_success(self) -> None:
172+
"""Test execution with multiple account IDs - mixed success and error."""
173+
tool = create_feedback_tool(api_key="test_key")
174+
175+
def custom_side_effect(request: httpx.Request) -> httpx.Response:
176+
body = json.loads(request.content)
177+
account_id = body.get("account_id")
181178
if account_id == "acc_123456":
182-
mock_response = Mock()
183-
mock_response.status_code = 200
184-
mock_response.json.return_value = {"message": "Success"}
185-
mock_response.raise_for_status = Mock()
186-
return mock_response
179+
return httpx.Response(200, json={"message": "Success"})
187180
else:
188-
mock_response = Mock()
189-
mock_response.status_code = 401
190-
mock_response.text = '{"error": "Unauthorized"}'
191-
mock_response.json.return_value = {"error": "Unauthorized"}
192-
mock_response.raise_for_status.side_effect = Exception("401 Client Error: Unauthorized")
193-
return mock_response
181+
return httpx.Response(401, json={"error": "Unauthorized"})
194182

195-
with patch("requests.request") as mock_request:
196-
mock_request.side_effect = mock_request_side_effect
183+
respx.post("https://api.stackone.com/ai/tool-feedback").mock(side_effect=custom_side_effect)
197184

198-
result = tool.execute(
199-
{
200-
"feedback": "Great tools!",
201-
"account_id": ["acc_123456", "acc_unauthorized"],
202-
"tool_names": ["test_tool"],
203-
}
204-
)
185+
result = tool.execute(
186+
{
187+
"feedback": "Great tools!",
188+
"account_id": ["acc_123456", "acc_unauthorized"],
189+
"tool_names": ["test_tool"],
190+
}
191+
)
205192

206-
assert result["total_accounts"] == 2
207-
assert result["successful"] == 1
208-
assert result["failed"] == 1
209-
assert len(result["results"]) == 2
193+
assert result["total_accounts"] == 2
194+
assert result["successful"] == 1
195+
assert result["failed"] == 1
196+
assert len(result["results"]) == 2
210197

211-
success_result = next(r for r in result["results"] if r["account_id"] == "acc_123456")
212-
assert success_result["status"] == "success"
198+
success_result = next(r for r in result["results"] if r["account_id"] == "acc_123456")
199+
assert success_result["status"] == "success"
213200

214-
error_result = next(r for r in result["results"] if r["account_id"] == "acc_unauthorized")
215-
assert error_result["status"] == "error"
216-
assert "401 Client Error: Unauthorized" in error_result["error"]
201+
error_result = next(r for r in result["results"] if r["account_id"] == "acc_unauthorized")
202+
assert error_result["status"] == "error"
217203

218204
def test_tool_integration(self) -> None:
219205
"""Test that feedback tool integrates properly with toolset."""

tests/test_meta_tools.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Tests for meta tools functionality"""
22

3+
import httpx
34
import pytest
4-
import responses
5+
import respx
56

67
from stackone_ai import StackOneTool, Tools
78
from stackone_ai.meta_tools import (
@@ -209,24 +210,22 @@ def test_execute_tool_invalid_name(self, tools_collection):
209210
}
210211
)
211212

213+
@respx.mock
212214
def test_execute_tool_call(self, tools_collection):
213215
"""Test calling the execute tool with call method"""
214216
execute_tool = create_meta_execute_tool(tools_collection)
215217

216-
# Mock the actual tool execution by patching the requests
217-
with responses.RequestsMock() as rsps:
218-
rsps.add(
219-
responses.GET,
220-
"https://api.example.com/hris/employee",
221-
json={"success": True, "employees": []},
222-
status=200,
223-
)
218+
# Mock the actual tool execution
219+
route = respx.get("https://api.example.com/hris/employee").mock(
220+
return_value=httpx.Response(200, json={"success": True, "employees": []})
221+
)
224222

225-
# Call the meta execute tool
226-
result = execute_tool.call(toolName="hris_list_employee", params={"limit": 10})
223+
# Call the meta execute tool
224+
result = execute_tool.call(toolName="hris_list_employee", params={"limit": 10})
227225

228-
assert result is not None
229-
assert "success" in result or "employees" in result
226+
assert result is not None
227+
assert "success" in result or "employees" in result
228+
assert route.called
230229

231230

232231
class TestToolsMetaTools:

0 commit comments

Comments
 (0)