mm-http 0.1.4__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(uv run ruff:*)",
5
+ "Bash(uv run:*)",
6
+ "mcp__ide__getDiagnostics",
7
+ "Bash(just lint)",
8
+ "Bash(just test:*)"
9
+ ],
10
+ "deny": [],
11
+ "ask": []
12
+ }
13
+ }
@@ -0,0 +1,13 @@
1
+ # Claude Guidelines
2
+
3
+ ## Critical Guidelines
4
+
5
+ 1. **Always communicate in English** - Regardless of the language the user speaks, always respond in English. All code, comments, and documentation must be in English.
6
+
7
+ 2. **Minimal documentation** - Only add comments/documentation when it simplifies understanding and isn't obvious from the code itself. Keep it strictly relevant and concise.
8
+
9
+ 3. **Critical thinking** - Always critically evaluate user ideas. Users can make mistakes. Think first about whether the user's idea is good before implementing.
10
+
11
+ 4. **Lint after changes** - After making code changes, always run `just lint` to verify code quality and fix any linter issues.
12
+
13
+ 5. **No disabling linter rules** - Never use special disabling comments (like `# noqa`, `# type: ignore`, `# ruff: noqa`, etc.) to turn off linter rules without explicit permission. If you believe a rule should be disabled, ask first.
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mm-http
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: aiohttp-socks~=0.10.1
6
- Requires-Dist: aiohttp~=3.12.15
7
- Requires-Dist: mm-result~=0.1.1
6
+ Requires-Dist: aiohttp~=3.13.0
7
+ Requires-Dist: mm-result~=0.1.2
8
+ Requires-Dist: pydantic~=2.12.0
8
9
  Requires-Dist: pydash~=8.0.5
9
10
  Requires-Dist: requests[socks]~=2.32.5
10
- Requires-Dist: urllib3~=2.5.0
@@ -21,7 +21,7 @@ from mm_http import http_request
21
21
 
22
22
  # Simple GET request
23
23
  response = await http_request("https://api.github.com/users/octocat")
24
- user_name = response.parse_json_body("name") # Navigate JSON with dot notation
24
+ user_name = response.parse_json("name") # Navigate JSON with dot notation
25
25
 
26
26
  # POST with JSON data
27
27
  response = await http_request(
@@ -45,7 +45,7 @@ from mm_http import http_request_sync
45
45
 
46
46
  # Same API, but synchronous
47
47
  response = http_request_sync("https://api.github.com/users/octocat")
48
- user_name = response.parse_json_body("name")
48
+ user_name = response.parse_json("name")
49
49
  ```
50
50
 
51
51
  ## API Reference
@@ -60,9 +60,9 @@ user_name = response.parse_json_body("name")
60
60
  - `url: str` - Request URL
61
61
  - `method: str = "GET"` - HTTP method
62
62
  - `params: dict[str, Any] | None = None` - URL query parameters
63
- - `data: dict[str, object] | None = None` - Form data
64
- - `json: dict[str, object] | None = None` - JSON data
65
- - `headers: dict[str, str] | None = None` - HTTP headers
63
+ - `data: dict[str, Any] | None = None` - Form data
64
+ - `json: dict[str, Any] | None = None` - JSON data
65
+ - `headers: dict[str, Any] | None = None` - HTTP headers
66
66
  - `cookies: LooseCookies | None = None` - Cookies
67
67
  - `user_agent: str | None = None` - User-Agent header
68
68
  - `proxy: str | None = None` - Proxy URL (supports http://, https://, socks4://, socks5://)
@@ -71,25 +71,39 @@ user_name = response.parse_json_body("name")
71
71
  ### HttpResponse
72
72
 
73
73
  ```python
74
- @dataclass
75
- class HttpResponse:
74
+ class HttpResponse(BaseModel):
76
75
  status_code: int | None
77
- error: HttpError | None
78
- error_message: str | None
79
76
  body: str | None
80
77
  headers: dict[str, str] | None
78
+ transport_error: TransportErrorDetail | None
81
79
 
82
- def parse_json_body(self, path: str | None = None, none_on_error: bool = False) -> Any
83
- def is_err(self) -> bool
84
- def content_type(self) -> str | None
80
+ # JSON parsing
81
+ def parse_json(self, path: str | None = None, none_on_error: bool = False) -> Any
82
+
83
+ # Header access
84
+ def get_header(self, name: str) -> str | None
85
+ @property content_type(self) -> str | None
86
+
87
+ # Status checks
88
+ def is_success(self) -> bool # 2xx status
89
+ def is_err(self) -> bool # Has transport error or status >= 400
90
+
91
+ # Result conversion
85
92
  def to_result_ok[T](self, value: T) -> Result[T]
86
93
  def to_result_err[T](self, error: str | Exception | None = None) -> Result[T]
94
+
95
+ # Pydantic methods
96
+ def model_dump(self, mode: str = "python") -> dict[str, Any]
97
+
98
+ class TransportErrorDetail(BaseModel):
99
+ type: TransportError
100
+ message: str
87
101
  ```
88
102
 
89
103
  ### Error Types
90
104
 
91
105
  ```python
92
- class HttpError(str, Enum):
106
+ class TransportError(str, Enum):
93
107
  TIMEOUT = "timeout"
94
108
  PROXY = "proxy"
95
109
  INVALID_URL = "invalid_url"
@@ -105,11 +119,14 @@ class HttpError(str, Enum):
105
119
  response = await http_request("https://api.github.com/users/octocat")
106
120
 
107
121
  # Instead of: json.loads(response.body)["plan"]["name"]
108
- plan_name = response.parse_json_body("plan.name")
122
+ plan_name = response.parse_json("plan.name")
109
123
 
110
124
  # Safe navigation - returns None if path doesn't exist
111
- followers = response.parse_json_body("followers_count")
112
- nonexistent = response.parse_json_body("does.not.exist") # Returns None
125
+ followers = response.parse_json("followers_count")
126
+ nonexistent = response.parse_json("does.not.exist") # Returns None
127
+
128
+ # Or get full JSON
129
+ data = response.parse_json()
113
130
  ```
114
131
 
115
132
  ### Error Handling
@@ -117,8 +134,16 @@ nonexistent = response.parse_json_body("does.not.exist") # Returns None
117
134
  ```python
118
135
  response = await http_request("https://example.com", timeout=5.0)
119
136
 
137
+ # Simple check
138
+ if response.is_success():
139
+ data = response.parse_json()
140
+
141
+ # Detailed error handling
120
142
  if response.is_err():
121
- print(f"Request failed: {response.error} - {response.error_message}")
143
+ if response.transport_error:
144
+ print(f"Transport error: {response.transport_error.type} - {response.transport_error.message}")
145
+ elif response.status_code >= 400:
146
+ print(f"HTTP error: {response.status_code}")
122
147
  else:
123
148
  print(f"Success: {response.status_code}")
124
149
  ```
@@ -163,17 +188,17 @@ async def get_user_id() -> Result[int]:
163
188
  response = await http_request("https://api.example.com/user")
164
189
 
165
190
  if response.is_err():
166
- return response.to_result_err() # Convert error to Result[T]
191
+ return response.to_result_err() # Returns "HTTP 404" or TransportError.TIMEOUT
167
192
 
168
- user_id = response.parse_json_body("id")
169
- return response.to_result_ok(user_id) # Convert success to Result[T]
193
+ user_id = response.parse_json("id")
194
+ return response.to_result_ok(user_id)
170
195
 
171
196
  # Usage
172
197
  result = await get_user_id()
173
198
  if result.is_ok():
174
199
  print(f"User ID: {result.value}")
175
200
  else:
176
- print(f"Error: {result.error}")
201
+ print(f"Error: {result.error}") # "HTTP 404" or TransportError.TIMEOUT
177
202
  print(f"HTTP details: {result.extra}") # Contains full HTTP response data
178
203
  ```
179
204
 
@@ -1,34 +1,33 @@
1
1
  [project]
2
2
  name = "mm-http"
3
- version = "0.1.4"
3
+ version = "0.2.0"
4
4
  description = ""
5
5
  requires-python = ">=3.13"
6
6
  dependencies = [
7
- "mm-result~=0.1.1",
7
+ "mm-result~=0.1.2",
8
8
  "requests[socks]~=2.32.5",
9
- "aiohttp~=3.12.15",
9
+ "aiohttp~=3.13.0",
10
10
  "aiohttp-socks~=0.10.1",
11
11
  "pydash~=8.0.5",
12
- "urllib3~=2.5.0", # urllib3 is a dependency of requests, temporarily added to avoid GHSA-48p4-8xcf-vxj5, GHSA-pq67-6m6q-mj2v
13
-
12
+ "pydantic~=2.12.0",
14
13
  ]
15
14
 
16
15
  [build-system]
17
16
  requires = ["hatchling"]
18
17
  build-backend = "hatchling.build"
19
18
 
20
- [tool.uv]
21
- dev-dependencies = [
22
- "pytest~=8.4.1",
23
- "pytest-asyncio~=1.1.0",
19
+ [dependency-groups]
20
+ dev = [
21
+ "pytest~=8.4.2",
22
+ "pytest-asyncio~=1.2.0",
24
23
  "pytest-xdist~=3.8.0",
25
24
  "pytest-httpserver~=1.1.3",
26
- "ruff~=0.12.10",
27
- "mypy~=1.17.1",
25
+ "ruff~=0.14.0",
26
+ "mypy~=1.18.2",
28
27
  "pip-audit~=2.9.0",
29
28
  "bandit~=1.8.6",
30
29
  "pre-commit~=4.3.0",
31
- "types-requests~=2.32.4.20250809",
30
+ "types-requests~=2.32.4.20250913",
32
31
  "python-dotenv~=1.1.1",
33
32
  ]
34
33
 
@@ -0,0 +1,5 @@
1
+ from .http_request import http_request
2
+ from .http_request_sync import http_request_sync
3
+ from .response import HttpResponse, TransportError, TransportErrorDetail
4
+
5
+ __all__ = ["HttpResponse", "TransportError", "TransportErrorDetail", "http_request", "http_request_sync"]
@@ -1,12 +1,20 @@
1
1
  from typing import Any
2
2
 
3
3
  import aiohttp
4
- from aiohttp import ClientHttpProxyError, InvalidUrlClientError
4
+ from aiohttp import (
5
+ ClientConnectionError,
6
+ ClientConnectorError,
7
+ ClientHttpProxyError,
8
+ ClientSSLError,
9
+ InvalidUrlClientError,
10
+ ServerConnectionError,
11
+ ServerDisconnectedError,
12
+ )
5
13
  from aiohttp.typedefs import LooseCookies
6
14
  from aiohttp_socks import ProxyConnectionError, ProxyConnector
7
15
  from multidict import CIMultiDictProxy
8
16
 
9
- from .response import HttpError, HttpResponse
17
+ from .response import HttpResponse, TransportError, TransportErrorDetail
10
18
 
11
19
 
12
20
  async def http_request(
@@ -14,9 +22,9 @@ async def http_request(
14
22
  *,
15
23
  method: str = "GET",
16
24
  params: dict[str, Any] | None = None,
17
- data: dict[str, object] | None = None,
18
- json: dict[str, object] | None = None,
19
- headers: dict[str, str] | None = None,
25
+ data: dict[str, Any] | None = None,
26
+ json: dict[str, Any] | None = None,
27
+ headers: dict[str, Any] | None = None,
20
28
  cookies: LooseCookies | None = None,
21
29
  user_agent: str | None = None,
22
30
  proxy: str | None = None,
@@ -56,13 +64,21 @@ async def http_request(
56
64
  timeout=timeout_,
57
65
  )
58
66
  except TimeoutError as err:
59
- return HttpResponse(error=HttpError.TIMEOUT, error_message=str(err))
67
+ return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.TIMEOUT, message=str(err)))
60
68
  except (aiohttp.ClientProxyConnectionError, ProxyConnectionError, ClientHttpProxyError) as err:
61
- return HttpResponse(error=HttpError.PROXY, error_message=str(err))
69
+ return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.PROXY, message=str(err)))
62
70
  except InvalidUrlClientError as e:
63
- return HttpResponse(error=HttpError.INVALID_URL, error_message=str(e))
71
+ return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.INVALID_URL, message=str(e)))
72
+ except (
73
+ ClientConnectorError,
74
+ ServerConnectionError,
75
+ ServerDisconnectedError,
76
+ ClientSSLError,
77
+ ClientConnectionError,
78
+ ) as err:
79
+ return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.CONNECTION, message=str(err)))
64
80
  except Exception as err:
65
- return HttpResponse(error=HttpError.ERROR, error_message=str(err))
81
+ return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.ERROR, message=str(err)))
66
82
 
67
83
 
68
84
  async def _request_with_http_or_none_proxy(
@@ -70,9 +86,9 @@ async def _request_with_http_or_none_proxy(
70
86
  *,
71
87
  method: str = "GET",
72
88
  params: dict[str, Any] | None = None,
73
- data: dict[str, object] | None = None,
74
- json: dict[str, object] | None = None,
75
- headers: dict[str, str] | None = None,
89
+ data: dict[str, Any] | None = None,
90
+ json: dict[str, Any] | None = None,
91
+ headers: dict[str, Any] | None = None,
76
92
  cookies: LooseCookies | None = None,
77
93
  proxy: str | None = None,
78
94
  timeout: aiohttp.ClientTimeout | None,
@@ -82,9 +98,7 @@ async def _request_with_http_or_none_proxy(
82
98
  ) as res:
83
99
  return HttpResponse(
84
100
  status_code=res.status,
85
- error=None,
86
- error_message=None,
87
- body=(await res.read()).decode(),
101
+ body=await res.text(),
88
102
  headers=headers_dict(res.headers),
89
103
  )
90
104
 
@@ -95,9 +109,9 @@ async def _request_with_socks_proxy(
95
109
  method: str = "GET",
96
110
  proxy: str,
97
111
  params: dict[str, Any] | None = None,
98
- data: dict[str, object] | None = None,
99
- json: dict[str, object] | None = None,
100
- headers: dict[str, str] | None = None,
112
+ data: dict[str, Any] | None = None,
113
+ json: dict[str, Any] | None = None,
114
+ headers: dict[str, Any] | None = None,
101
115
  cookies: LooseCookies | None = None,
102
116
  timeout: aiohttp.ClientTimeout | None,
103
117
  ) -> HttpResponse:
@@ -110,9 +124,7 @@ async def _request_with_socks_proxy(
110
124
  ):
111
125
  return HttpResponse(
112
126
  status_code=res.status,
113
- error=None,
114
- error_message=None,
115
- body=(await res.read()).decode(),
127
+ body=await res.text(),
116
128
  headers=headers_dict(res.headers),
117
129
  )
118
130
 
@@ -1,9 +1,17 @@
1
1
  from typing import Any
2
2
 
3
3
  import requests
4
- from requests.exceptions import InvalidSchema, MissingSchema, ProxyError
4
+ from requests.exceptions import (
5
+ ConnectionError as RequestsConnectionError,
6
+ )
7
+ from requests.exceptions import (
8
+ InvalidSchema,
9
+ MissingSchema,
10
+ ProxyError,
11
+ SSLError,
12
+ )
5
13
 
6
- from .response import HttpError, HttpResponse
14
+ from .response import HttpResponse, TransportError, TransportErrorDetail
7
15
 
8
16
 
9
17
  def http_request_sync(
@@ -48,16 +56,16 @@ def http_request_sync(
48
56
  )
49
57
  return HttpResponse(
50
58
  status_code=res.status_code,
51
- error=None,
52
- error_message=None,
53
59
  body=res.text,
54
60
  headers=dict(res.headers),
55
61
  )
56
62
  except requests.Timeout as e:
57
- return HttpResponse(error=HttpError.TIMEOUT, error_message=str(e))
63
+ return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.TIMEOUT, message=str(e)))
58
64
  except ProxyError as e:
59
- return HttpResponse(error=HttpError.PROXY, error_message=str(e))
65
+ return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.PROXY, message=str(e)))
60
66
  except (InvalidSchema, MissingSchema) as e:
61
- return HttpResponse(error=HttpError.INVALID_URL, error_message=str(e))
67
+ return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.INVALID_URL, message=str(e)))
68
+ except (RequestsConnectionError, SSLError) as e:
69
+ return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.CONNECTION, message=str(e)))
62
70
  except Exception as e:
63
- return HttpResponse(error=HttpError.ERROR, error_message=str(e))
71
+ return HttpResponse(transport_error=TransportErrorDetail(type=TransportError.ERROR, message=str(e)))
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
4
+ import json
5
+ from typing import Any
6
+
7
+ import pydash
8
+ from mm_result import Result
9
+ from pydantic import BaseModel, model_validator
10
+
11
+
12
+ @enum.unique
13
+ class TransportError(str, enum.Enum):
14
+ TIMEOUT = "timeout"
15
+ PROXY = "proxy"
16
+ INVALID_URL = "invalid_url"
17
+ CONNECTION = "connection"
18
+ ERROR = "error"
19
+
20
+
21
+ class TransportErrorDetail(BaseModel):
22
+ """Transport error with type and message."""
23
+
24
+ type: TransportError
25
+ message: str
26
+
27
+
28
+ class HttpResponse(BaseModel):
29
+ """HTTP response with status, body, headers, and optional transport error."""
30
+
31
+ status_code: int | None = None
32
+ body: str | None = None
33
+ headers: dict[str, str] | None = None
34
+ transport_error: TransportErrorDetail | None = None
35
+
36
+ @model_validator(mode="after")
37
+ def validate_mutually_exclusive_states(self) -> HttpResponse:
38
+ """Validate that response has either HTTP data or transport error, but not both."""
39
+ has_http_response = self.status_code is not None
40
+ has_transport_error = self.transport_error is not None
41
+
42
+ if has_http_response and has_transport_error:
43
+ msg = "Cannot have both HTTP response and transport error"
44
+ raise ValueError(msg)
45
+
46
+ if not has_http_response and not has_transport_error:
47
+ msg = "Must have either HTTP response or transport error"
48
+ raise ValueError(msg)
49
+
50
+ return self
51
+
52
+ def parse_json(self, path: str | None = None, none_on_error: bool = False) -> Any: # noqa: ANN401
53
+ """Parse JSON body and optionally extract value by path."""
54
+ if self.body is None:
55
+ if none_on_error:
56
+ return None
57
+ raise ValueError("Body is None")
58
+
59
+ try:
60
+ res = json.loads(self.body)
61
+ return pydash.get(res, path, None) if path else res
62
+ except json.JSONDecodeError:
63
+ if none_on_error:
64
+ return None
65
+ raise
66
+
67
+ def get_header(self, name: str) -> str | None:
68
+ """Get header value (case-insensitive)."""
69
+ if self.headers is None:
70
+ return None
71
+ name_lower = name.lower()
72
+ for key, value in self.headers.items():
73
+ if key.lower() == name_lower:
74
+ return value
75
+ return None
76
+
77
+ def is_success(self) -> bool:
78
+ """Check if response has 2xx status."""
79
+ return self.status_code is not None and 200 <= self.status_code < 300
80
+
81
+ def is_err(self) -> bool:
82
+ """Check if response represents an error (has transport error or status >= 400)."""
83
+ return self.transport_error is not None or (self.status_code is not None and self.status_code >= 400)
84
+
85
+ def to_result_err[T](self, error: str | Exception | tuple[str, Exception] | None = None) -> Result[T]:
86
+ """Create error Result[T] from HttpResponse with meaningful error message."""
87
+ if error is not None:
88
+ result_error = error
89
+ elif self.transport_error is not None:
90
+ result_error = self.transport_error.type
91
+ elif self.status_code is not None:
92
+ result_error = f"HTTP {self.status_code}"
93
+ else:
94
+ result_error = "error"
95
+ return Result.err(result_error, extra=self.model_dump(mode="json"))
96
+
97
+ def to_result_ok[T](self, value: T) -> Result[T]:
98
+ """Create success Result[T] from HttpResponse with given value."""
99
+ return Result.ok(value, extra=self.model_dump(mode="json"))
100
+
101
+ @property
102
+ def content_type(self) -> str | None:
103
+ """Get Content-Type header value (case-insensitive)."""
104
+ return self.get_header("content-type")
@@ -5,19 +5,19 @@ from urllib.parse import urlencode, urlparse
5
5
  from pytest_httpserver import HTTPServer
6
6
  from werkzeug import Request, Response
7
7
 
8
- from mm_http import HttpError, http_request
8
+ from mm_http import TransportError, http_request
9
9
 
10
10
 
11
11
  async def test_json_path(httpserver: HTTPServer):
12
12
  httpserver.expect_request("/test").respond_with_json({"a": {"b": {"c": 123}}})
13
13
  res = await http_request(httpserver.url_for("/test"))
14
- assert res.parse_json_body("a.b.c") == 123
14
+ assert res.parse_json("a.b.c") == 123
15
15
 
16
16
 
17
17
  async def test_body_as_json_path_not_exists(httpserver: HTTPServer):
18
18
  httpserver.expect_request("/test").respond_with_json({"d": 1})
19
19
  res = await http_request(httpserver.url_for("/test"))
20
- assert res.parse_json_body("a.b.c") is None
20
+ assert res.parse_json("a.b.c") is None
21
21
 
22
22
 
23
23
  async def test_body_as_json_no_body(httpserver: HTTPServer):
@@ -26,7 +26,7 @@ async def test_body_as_json_no_body(httpserver: HTTPServer):
26
26
 
27
27
  httpserver.expect_request("/test").respond_with_handler(handler)
28
28
  res = await http_request(httpserver.url_for("/test"))
29
- assert res.parse_json_body("a.b.c", none_on_error=True) is None
29
+ assert res.parse_json("a.b.c", none_on_error=True) is None
30
30
 
31
31
 
32
32
  async def test_custom_user_agent(httpserver: HTTPServer):
@@ -36,21 +36,21 @@ async def test_custom_user_agent(httpserver: HTTPServer):
36
36
  httpserver.expect_request("/test").respond_with_handler(handler)
37
37
  user_agent = "moon cat"
38
38
  res = await http_request(httpserver.url_for("/test"), user_agent=user_agent)
39
- assert res.parse_json_body()["user-agent"] == user_agent
39
+ assert res.parse_json()["user-agent"] == user_agent
40
40
 
41
41
 
42
42
  async def test_params(httpserver: HTTPServer):
43
43
  data = {"a": 123, "b": "bla bla"}
44
44
  httpserver.expect_request("/test", query_string="a=123&b=bla+bla").respond_with_json(data)
45
45
  res = await http_request(httpserver.url_for("/test"), params=data)
46
- assert res.parse_json_body() == data
46
+ assert res.parse_json() == data
47
47
 
48
48
 
49
49
  async def test_post_with_params(httpserver: HTTPServer):
50
50
  data = {"a": 1}
51
51
  httpserver.expect_request("/test", query_string=urlencode(data)).respond_with_json(data)
52
52
  res = await http_request(httpserver.url_for("/test"), params=data)
53
- assert res.parse_json_body() == data
53
+ assert res.parse_json() == data
54
54
 
55
55
 
56
56
  async def test_timeout(httpserver: HTTPServer):
@@ -60,26 +60,26 @@ async def test_timeout(httpserver: HTTPServer):
60
60
 
61
61
  httpserver.expect_request("/test").respond_with_handler(handler)
62
62
  res = await http_request(httpserver.url_for("/test"), timeout=1)
63
- assert res.error == HttpError.TIMEOUT
63
+ assert res.transport_error.type == TransportError.TIMEOUT
64
64
 
65
65
 
66
66
  async def test_proxy_http(proxy_http: str):
67
67
  proxy = urlparse(proxy_http)
68
68
  res = await http_request("https://api.ipify.org?format=json", proxy=proxy_http, timeout=5)
69
- assert proxy.hostname in res.parse_json_body()["ip"]
69
+ assert proxy.hostname in res.parse_json()["ip"]
70
70
 
71
71
 
72
72
  async def test_proxy_socks5(proxy_socks5):
73
73
  proxy = urlparse(proxy_socks5)
74
74
  res = await http_request("https://api.ipify.org?format=json", proxy=proxy_socks5, timeout=5)
75
- assert proxy.hostname in res.parse_json_body()["ip"]
75
+ assert proxy.hostname in res.parse_json()["ip"]
76
76
 
77
77
 
78
78
  async def test_http_request_invalid_url() -> None:
79
79
  """Test that http_request returns INVALID_URL error for malformed URLs."""
80
80
  response = await http_request("not-a-valid-url")
81
- assert response.error == HttpError.INVALID_URL
82
- assert response.error_message is not None
81
+ assert response.transport_error.type == TransportError.INVALID_URL
82
+ assert response.transport_error.message is not None
83
83
  assert response.status_code is None
84
84
  assert response.body is None
85
85
  assert response.headers is None
@@ -88,8 +88,26 @@ async def test_http_request_invalid_url() -> None:
88
88
  async def test_http_request_invalid_url_with_proxy() -> None:
89
89
  """Test that http_request returns INVALID_URL error for malformed URLs even with proxy."""
90
90
  response = await http_request("not-a-valid-url", proxy="http://proxy.example.com:8080")
91
- assert response.error == HttpError.INVALID_URL
92
- assert response.error_message is not None
91
+ assert response.transport_error.type == TransportError.INVALID_URL
92
+ assert response.transport_error.message is not None
93
93
  assert response.status_code is None
94
94
  assert response.body is None
95
95
  assert response.headers is None
96
+
97
+
98
+ async def test_connection_error_refused() -> None:
99
+ """Test CONNECTION error when port is not listening."""
100
+ response = await http_request("http://localhost:59999/test", timeout=2)
101
+ assert response.transport_error.type == TransportError.CONNECTION
102
+ assert response.transport_error.message is not None
103
+ assert response.status_code is None
104
+ assert response.body is None
105
+
106
+
107
+ async def test_connection_error_dns() -> None:
108
+ """Test CONNECTION error when DNS resolution fails."""
109
+ response = await http_request("http://this-host-does-not-exist-xyz123.invalid/", timeout=5)
110
+ assert response.transport_error.type == TransportError.CONNECTION
111
+ assert response.transport_error.message is not None
112
+ assert response.status_code is None
113
+ assert response.body is None