Skip to content

Commit d26031d

Browse files
committed
Move HTTPProvider into its own feature
1 parent 2fe06f1 commit d26031d

11 files changed

Lines changed: 508 additions & 471 deletions

docs/api.rst

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,20 @@ Providers
2323
.. autoclass:: Provider
2424
:show-inheritance:
2525

26-
.. autoclass:: HTTPProvider
26+
.. autoclass:: ProviderPath()
27+
28+
29+
HTTP
30+
^^^^
31+
32+
Install the feature ``http-provider`` for the ``http_provider`` module to be available.
33+
34+
.. autoclass:: pons.http_provider.HTTPProvider
2735
:show-inheritance:
2836

29-
.. autoclass:: ProviderPath()
37+
.. autoclass:: pons.http_provider.HTTPError()
38+
:show-inheritance:
39+
:members:
3040

3141

3242
Fallback providers
@@ -70,8 +80,6 @@ Provider level
7080
.. autoclass:: ProtocolError
7181
:show-inheritance:
7282

73-
.. autoclass:: HTTPError
74-
:show-inheritance:
7583

7684
Client level
7785
^^^^^^^^^^^^

docs/changelog.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Changed
99
^^^^^^^
1010

1111
- ``ContractABI`` interface modified to allow for methods, errors, and events with arbitrary anonymous parameters and fields. Instead of returning ``dict[str, Any]`` as a parsed struct we now return a ``FieldValues`` object. Signatures of methods and fields of errors are exposed as ``Fields``, fields of events are exposed as ``EventFields`` objects. (PR_92_)
12+
- ``HTTPProvider`` and ``HTTPError`` are moved to ``http_provider`` submodule, which is optional and gated under the ``http-provider`` feature. (PR_94_)
1213

1314

1415
Added
@@ -29,6 +30,7 @@ Fixed
2930
.. _PR_91: https://github.com/fjarri-eth/pons/pull/91
3031
.. _PR_92: https://github.com/fjarri-eth/pons/pull/92
3132
.. _PR_93: https://github.com/fjarri-eth/pons/pull/93
33+
.. _PR_94: https://github.com/fjarri-eth/pons/pull/94
3234

3335

3436
0.10.0 (2025-10-19)

pdm.lock

Lines changed: 135 additions & 135 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pons/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,6 @@
5555
)
5656
from ._multicall import BoundMultiMethodCall, BoundMultiMethodValueCall, Multicall
5757
from ._provider import (
58-
HTTPError,
59-
HTTPProvider,
6058
InvalidResponse,
6159
ProtocolError,
6260
Provider,

pons/_provider.py

Lines changed: 1 addition & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,8 @@
22
from collections.abc import AsyncIterator, Iterable, Mapping, Sequence
33
from contextlib import asynccontextmanager
44
from dataclasses import dataclass
5-
from http import HTTPStatus
6-
from json import JSONDecodeError
7-
from typing import cast
85

9-
import httpx
10-
from compages import StructuringError
11-
from ethereum_rpc import RPCError, structure
6+
from ethereum_rpc import RPCError
127

138
RPC_JSON = None | bool | int | float | str | Sequence["RPC_JSON"] | Mapping[str, "RPC_JSON"]
149
"""RPC requests and responses serializable to JSON."""
@@ -31,33 +26,6 @@ class ProtocolError(ABC, Exception):
3126
"""
3227

3328

34-
class HTTPError(ProtocolError):
35-
"""
36-
Raised when the provider returns a response with a status code other than 200,
37-
and no ``"error"`` field in the associated JSON data.
38-
"""
39-
40-
status: HTTPStatus
41-
"""The HTTP status of the response."""
42-
43-
message: str
44-
"""The response body."""
45-
46-
def __init__(self, status_code: int, message: str):
47-
try:
48-
status = HTTPStatus(status_code)
49-
except ValueError: # pragma: no cover
50-
# How to handle it better? Ideally, `httpx` should have returned a parsed status
51-
# in the first place, but, alas, it just gives us an integer.
52-
status = HTTPStatus.INTERNAL_SERVER_ERROR
53-
54-
self.status = status
55-
self.message = message
56-
57-
def __str__(self) -> str:
58-
return f"HTTP status {self.status}: {self.message}"
59-
60-
6129
@dataclass
6230
class ProviderError(Exception):
6331
"""Describes an error on the provider's side."""
@@ -149,68 +117,3 @@ async def rpc_at_pin(self, path: ProviderPath, method: str, *args: RPC_JSON) ->
149117
if not path.is_empty():
150118
raise ValueError(f"Expected an empty provider path, got: `{path}`")
151119
return await self.rpc(method, *args)
152-
153-
154-
class HTTPProvider(Provider):
155-
"""A provider for RPC via HTTP(S)."""
156-
157-
def __init__(self, url: str):
158-
self._url = url
159-
160-
@asynccontextmanager
161-
async def session(self) -> AsyncIterator["HTTPSession"]:
162-
async with httpx.AsyncClient() as client:
163-
yield HTTPSession(self._url, client)
164-
165-
166-
class HTTPSession(ProviderSession):
167-
def __init__(self, url: str, http_client: httpx.AsyncClient):
168-
self._url = url
169-
self._client = http_client
170-
171-
def _prepare_request(self, method: str, *args: RPC_JSON) -> RPC_JSON:
172-
return {"jsonrpc": "2.0", "method": method, "params": args, "id": 0}
173-
174-
async def rpc(self, method: str, *args: RPC_JSON) -> RPC_JSON:
175-
json = self._prepare_request(method, *args)
176-
try:
177-
response = await self._client.post(self._url, json=json)
178-
except httpx.ConnectError as exc:
179-
raise ProviderError(Unreachable(str(exc))) from exc
180-
181-
status = response.status_code
182-
183-
try:
184-
response_json = response.json()
185-
except JSONDecodeError as exc:
186-
content = response.content.decode()
187-
raise ProviderError(
188-
InvalidResponse(f"Expected a JSON response, got HTTP status {status}: {content}")
189-
) from exc
190-
191-
if not isinstance(response_json, Mapping):
192-
raise ProviderError(
193-
InvalidResponse(f"RPC response must be a dictionary, got: {response_json}")
194-
)
195-
response_json = cast("Mapping[str, RPC_JSON]", response_json)
196-
197-
# Note that the Eth-side errors (e.g. transaction having been reverted)
198-
# will have the HTTP status 200, so we are checking for the "error" field first.
199-
if "error" in response_json:
200-
try:
201-
error = structure(RPCError, response_json["error"])
202-
except StructuringError as exc:
203-
raise ProviderError(
204-
InvalidResponse(f"Failed to parse an error response: {response_json}")
205-
) from exc
206-
207-
raise ProviderError(error)
208-
209-
if status == HTTPStatus.OK:
210-
if "result" in response_json:
211-
return response_json["result"]
212-
raise ProviderError(
213-
InvalidResponse(f"`result` is not present in the response: {response_json}")
214-
)
215-
216-
raise ProviderError(HTTPError(status, response.content.decode()))

pons/http_provider.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""HTTP provider based on `httpx`."""
2+
3+
from collections.abc import AsyncIterator, Mapping
4+
from contextlib import asynccontextmanager
5+
from http import HTTPStatus
6+
from json import JSONDecodeError
7+
from typing import cast
8+
9+
import httpx
10+
from compages import StructuringError
11+
from ethereum_rpc import RPCError, structure
12+
13+
from ._provider import (
14+
RPC_JSON,
15+
InvalidResponse,
16+
ProtocolError,
17+
Provider,
18+
ProviderError,
19+
ProviderSession,
20+
Unreachable,
21+
)
22+
23+
__all__ = ["HTTPError", "HTTPProvider"]
24+
25+
26+
class HTTPError(ProtocolError):
27+
"""
28+
Raised when the provider returns a response with a status code other than 200,
29+
and no ``"error"`` field in the associated JSON data.
30+
"""
31+
32+
status: HTTPStatus
33+
"""The HTTP status of the response."""
34+
35+
message: str
36+
"""The response body."""
37+
38+
def __init__(self, status_code: int, message: str):
39+
try:
40+
status = HTTPStatus(status_code)
41+
except ValueError: # pragma: no cover
42+
# How to handle it better? Ideally, `httpx` should have returned a parsed status
43+
# in the first place, but, alas, it just gives us an integer.
44+
status = HTTPStatus.INTERNAL_SERVER_ERROR
45+
46+
self.status = status
47+
self.message = message
48+
49+
def __str__(self) -> str: # noqa: D105
50+
return f"HTTP status {self.status}: {self.message}"
51+
52+
53+
class HTTPProvider(Provider):
54+
"""A provider for RPC via HTTP(S)."""
55+
56+
def __init__(self, url: str):
57+
self._url = url
58+
59+
@asynccontextmanager
60+
async def session(self) -> AsyncIterator["HTTPProviderSession"]: # noqa: D102
61+
async with httpx.AsyncClient() as client:
62+
yield HTTPProviderSession(self._url, client)
63+
64+
65+
class HTTPProviderSession(ProviderSession):
66+
def __init__(self, url: str, http_client: httpx.AsyncClient):
67+
self._url = url
68+
self._client = http_client
69+
70+
def _prepare_request(self, method: str, *args: RPC_JSON) -> RPC_JSON:
71+
return {"jsonrpc": "2.0", "method": method, "params": args, "id": 0}
72+
73+
async def rpc(self, method: str, *args: RPC_JSON) -> RPC_JSON:
74+
json = self._prepare_request(method, *args)
75+
try:
76+
response = await self._client.post(self._url, json=json)
77+
except httpx.ConnectError as exc:
78+
raise ProviderError(Unreachable(str(exc))) from exc
79+
80+
status = response.status_code
81+
82+
try:
83+
response_json = response.json()
84+
except JSONDecodeError as exc:
85+
content = response.content.decode()
86+
raise ProviderError(
87+
InvalidResponse(f"Expected a JSON response, got HTTP status {status}: {content}")
88+
) from exc
89+
90+
if not isinstance(response_json, Mapping):
91+
raise ProviderError(
92+
InvalidResponse(f"RPC response must be a dictionary, got: {response_json}")
93+
)
94+
response_json = cast("Mapping[str, RPC_JSON]", response_json)
95+
96+
# Note that the Eth-side errors (e.g. transaction having been reverted)
97+
# will have the HTTP status 200, so we are checking for the "error" field first.
98+
if "error" in response_json:
99+
try:
100+
error = structure(RPCError, response_json["error"])
101+
except StructuringError as exc:
102+
raise ProviderError(
103+
InvalidResponse(f"Failed to parse an error response: {response_json}")
104+
) from exc
105+
106+
raise ProviderError(error)
107+
108+
if status == HTTPStatus.OK:
109+
if "result" in response_json:
110+
return response_json["result"]
111+
raise ProviderError(
112+
InvalidResponse(f"`result` is not present in the response: {response_json}")
113+
)
114+
115+
raise ProviderError(HTTPError(status, response.content.decode()))

pons/http_provider_server.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
from starlette.responses import JSONResponse, Response
1717
from starlette.routing import Route
1818

19-
from ._provider import RPC_JSON, HTTPProvider, Provider
19+
from ._provider import RPC_JSON, Provider
20+
from .http_provider import HTTPProvider
2021

2122
__all__ = ["HTTPProviderServer"]
2223

@@ -88,7 +89,7 @@ def make_app(provider: Provider) -> ASGIFramework:
8889

8990
class HTTPProviderServer:
9091
"""
91-
A server counterpart of :py:class:`pons.HTTPProvider`.
92+
A server counterpart of :py:class:`pons.http_provider.HTTPProvider`.
9293
Intended for testing, not production-ready.
9394
"""
9495

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ authors = [
66
{name = "Bogdan Opanchuk", email = "bogdan@opanchuk.net"},
77
]
88
dependencies = [
9-
"httpx>=0.22",
109
"eth-account>=0.13",
1110
"eth-utils>=2",
1211
"eth-abi>=5.0.1",
@@ -29,6 +28,9 @@ compiler = [
2928
local-provider = [
3029
"alysis>=0.6.1",
3130
]
31+
http-provider = [
32+
"httpx>=0.22",
33+
]
3234
http-provider-server = [
3335
"starlette>=0.48",
3436
"hypercorn>=0.17",

tests/test_fallback_provider.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from pons import (
1111
CycleFallback,
1212
FallbackProvider,
13-
HTTPError,
1413
InvalidResponse,
1514
PriorityFallback,
1615
ProtocolError,
@@ -21,6 +20,7 @@
2120
)
2221
from pons._fallback_provider import PriorityFallbackStrategy
2322
from pons._provider import RPC_JSON, ProviderSession
23+
from pons.http_provider import HTTPError
2424

2525

2626
def random_request() -> str:

0 commit comments

Comments
 (0)