Skip to content

Commit

Permalink
feat: rate limiting (#93)
Browse files Browse the repository at this point in the history
  • Loading branch information
NotPeopling2day authored Jan 3, 2025
1 parent 895397a commit 8de9fdb
Show file tree
Hide file tree
Showing 9 changed files with 117 additions and 9 deletions.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ repos:
additional_dependencies: [types-PyYAML, types-requests, pydantic, types-setuptools]

- repo: https://github.com/executablebooks/mdformat
rev: 0.7.19
rev: 0.7.21
hooks:
- id: mdformat
additional_dependencies: [mdformat-gfm, mdformat-frontmatter, mdformat-pyproject]
Expand Down
13 changes: 13 additions & 0 deletions ape_alchemy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
from ape import plugins


@plugins.register(plugins.Config)
def config_class():
from .config import AlchemyConfig

return AlchemyConfig


@plugins.register(plugins.ProviderPlugin)
def providers():
from ._utils import NETWORKS
Expand All @@ -22,10 +29,16 @@ def __getattr__(name: str):

return Alchemy

elif name == "AlcheymyConfig":
from .config import AlchemyConfig

return AlchemyConfig

raise AttributeError(name)


__all__ = [
"NETWORKS",
"Alchemy",
"AlchemyConfig",
]
13 changes: 10 additions & 3 deletions ape_alchemy/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,26 @@
"mainnet",
],
"avalanche": [
"fuji",
"mainnet",
"fuji",
],
"base": [
"mainnet",
"sepolia",
],
"berachain": ["bartio"],
"berachain": [
"bartio",
],
"blast": [
"mainnet",
"sepolia",
],
"bsc": ["mainnet", "testnet", "opbnb", "opbnb-testnet"],
"bsc": [
"mainnet",
"testnet",
"opbnb",
"opbnb-testnet",
],
"crossfi": [
"mainnet",
"testnet",
Expand Down
39 changes: 39 additions & 0 deletions ape_alchemy/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from ape.api import PluginConfig


class RateLimitConfig(PluginConfig):
"""
Configuration for rate limiting.
Args:
min_retry_delay (int): The amount of milliseconds to wait before
retrying the request. Defaults to ``1_000`` (one second).
retry_backoff_factor (int): The multiplier applied to the retry delay
after each failed attempt. Defaults to ``2``.
max_retry_delay (int): The maximum length of the retry delay.
Defaults to ``30_000`` (30 seconds).
max_retries (int): The maximum number of retries.
Defaults to ``3``.
retry_jitter (int): A random number of milliseconds up to this limit
is added to each retry delay. Defaults to ``250`` milliseconds.
"""

min_retry_delay: int = 1_000
retry_backoff_factor: int = 2
max_retry_delay: int = 30_000
max_retries: int = 3
retry_jitter: int = 250


class AlchemyConfig(PluginConfig):
"""
Configuration for Alchemy.
Args:
rate_limit (RateLimitConfig): The rate limiting configuration.
trace_timeout (int): The maximum amount of milliseconds to wait for a
trace. Defaults to ``10_000`` (10 seconds).
"""

rate_limit: RateLimitConfig = RateLimitConfig()
trace_timeout: str = "10s"
20 changes: 18 additions & 2 deletions ape_alchemy/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from ape.api import ReceiptAPI, TraceAPI, TransactionAPI, UpstreamProvider
from ape.exceptions import APINotImplementedError, ContractLogicError, VirtualMachineError
from ape.logging import logger
from ape.utils import request_with_retry
from ape_ethereum.provider import Web3Provider
from eth_pydantic_types import HexBytes
from eth_typing import HexStr
Expand Down Expand Up @@ -212,7 +213,14 @@ def disconnect(self):

def _get_prestate_trace(self, transaction_hash: str) -> dict:
return self.make_request(
"debug_traceTransaction", [transaction_hash, {"tracer": "prestateTracer"}]
"debug_traceTransaction",
[
transaction_hash,
{
"tracer": "prestateTracer",
"timeout": self.config.trace_timeout,
},
],
)

def get_transaction_trace(self, transaction_hash: str, **kwargs) -> TraceAPI:
Expand Down Expand Up @@ -261,9 +269,17 @@ def create_access_list(
return super().create_access_list(transaction, block_id=block_id)

def make_request(self, rpc: str, parameters: Optional[Iterable] = None) -> Any:
rate_limit = self.config.rate_limit
parameters = parameters or []
try:
result = self.web3.provider.make_request(RPCEndpoint(rpc), parameters)
result = request_with_retry(
lambda: self.web3.provider.make_request(RPCEndpoint(rpc), parameters),
min_retry_delay=rate_limit.min_retry_delay,
retry_backoff_factor=rate_limit.retry_backoff_factor,
max_retry_delay=rate_limit.max_retry_delay,
max_retries=rate_limit.max_retries,
retry_jitter=rate_limit.retry_jitter,
)
except HTTPError as err:
response_data = err.response.json() if err.response else {}
if "error" not in response_data:
Expand Down
8 changes: 7 additions & 1 deletion ape_alchemy/trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ def _top_level_call(self) -> dict:
"debug_traceTransaction",
[
self.transaction_hash,
{"tracer": "callTracer", "tracerConfig": {"onlyTopLevelCall": True}},
{
"tracer": "callTracer",
"timeout": self.provider.config.trace_timeout,
"tracerConfig": {
"onlyTopLevelCall": True,
},
},
],
)
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"flake8-pydantic", # For detecting issues with Pydantic models
"flake8-type-checking", # Detect imports to move in/out of type-checking blocks
"isort>=5.13.2,<6", # Import sorting linter
"mdformat>=0.7.19", # Auto-formatter for markdown
"mdformat>=0.7.21", # Auto-formatter for markdown
"mdformat-gfm>=0.3.5", # Needed for formatting GitHub-flavored markdown
"mdformat-frontmatter>=0.4.1", # Needed for frontmatters-style headers in issue templates
"mdformat-pyproject>=0.0.2", # Allows configuring in pyproject.toml
Expand Down
2 changes: 1 addition & 1 deletion tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def test_polygon_zkevm():
_ = provider.create_access_list(tx)


def test_make_requeset_handles_result():
def test_make_request_handles_result():
"""
There was a bug where eth_call because ape-alchemy wasn't
handling the result from make_request properly.
Expand Down
27 changes: 27 additions & 0 deletions tests/test_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from ape.exceptions import ContractLogicError
from ape.types import LogFilter
from hexbytes import HexBytes
from requests import HTTPError
from web3.exceptions import ContractLogicError as Web3ContractLogicError

from ape_alchemy.provider import MissingProjectKeyError
Expand Down Expand Up @@ -179,3 +180,29 @@ def test_get_transaction_trace(networks, alchemy_provider, mock_web3, parity_tra
actual = repr(trace.get_calltree())
expected = r"CALL: 0xC17f2C69aE2E66FD87367E3260412EEfF637F70E\.<0x96d373e5\> \[1401584 gas\]"
assert re.match(expected, actual)


def test_make_request_rate_limiting(mocker, alchemy_provider, mock_web3):
alchemy_provider._web3 = mock_web3

class RateLimitTester:
tries = 2
_try = 0
tries_made = 0

def rate_limit_hook(self, rpc, params):
self.tries_made += 1
if self._try == self.tries:
self._try = 0
return {"success": True}
else:
self._try += 1
response = mocker.MagicMock()
response.status_code = 429
raise HTTPError(response=response)

rate_limit_tester = RateLimitTester()
mock_web3.provider.make_request.side_effect = rate_limit_tester.rate_limit_hook
result = alchemy_provider.make_request("ape_testRateLimiting", parameters=[])
assert rate_limit_tester.tries_made == rate_limit_tester.tries + 1
assert result == {"success": True}

0 comments on commit 8de9fdb

Please sign in to comment.