brawny 0.1.13__py3-none-any.whl → 0.1.22__py3-none-any.whl
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.
- brawny/__init__.py +2 -0
- brawny/_context.py +5 -5
- brawny/_rpc/__init__.py +36 -12
- brawny/_rpc/broadcast.py +14 -13
- brawny/_rpc/caller.py +243 -0
- brawny/_rpc/client.py +539 -0
- brawny/_rpc/clients.py +11 -11
- brawny/_rpc/context.py +23 -0
- brawny/_rpc/errors.py +465 -31
- brawny/_rpc/gas.py +7 -6
- brawny/_rpc/pool.py +18 -0
- brawny/_rpc/retry.py +266 -0
- brawny/_rpc/retry_policy.py +81 -0
- brawny/accounts.py +28 -9
- brawny/alerts/__init__.py +15 -18
- brawny/alerts/abi_resolver.py +212 -36
- brawny/alerts/base.py +2 -2
- brawny/alerts/contracts.py +77 -10
- brawny/alerts/errors.py +30 -3
- brawny/alerts/events.py +38 -5
- brawny/alerts/health.py +19 -13
- brawny/alerts/send.py +513 -55
- brawny/api.py +39 -11
- brawny/assets/AGENTS.md +325 -0
- brawny/async_runtime.py +48 -0
- brawny/chain.py +3 -3
- brawny/cli/commands/__init__.py +2 -0
- brawny/cli/commands/console.py +69 -19
- brawny/cli/commands/contract.py +2 -2
- brawny/cli/commands/controls.py +121 -0
- brawny/cli/commands/health.py +2 -2
- brawny/cli/commands/job_dev.py +6 -5
- brawny/cli/commands/jobs.py +99 -2
- brawny/cli/commands/maintenance.py +13 -29
- brawny/cli/commands/migrate.py +1 -0
- brawny/cli/commands/run.py +10 -3
- brawny/cli/commands/script.py +8 -3
- brawny/cli/commands/signer.py +143 -26
- brawny/cli/helpers.py +0 -3
- brawny/cli_templates.py +25 -349
- brawny/config/__init__.py +4 -1
- brawny/config/models.py +43 -57
- brawny/config/parser.py +268 -57
- brawny/config/validation.py +52 -15
- brawny/daemon/context.py +4 -2
- brawny/daemon/core.py +185 -63
- brawny/daemon/loops.py +166 -98
- brawny/daemon/supervisor.py +261 -0
- brawny/db/__init__.py +14 -26
- brawny/db/base.py +248 -151
- brawny/db/global_cache.py +11 -1
- brawny/db/migrate.py +175 -28
- brawny/db/migrations/001_init.sql +4 -3
- brawny/db/migrations/010_add_nonce_gap_index.sql +1 -1
- brawny/db/migrations/011_add_job_logs.sql +1 -2
- brawny/db/migrations/012_add_claimed_by.sql +2 -2
- brawny/db/migrations/013_attempt_unique.sql +10 -0
- brawny/db/migrations/014_add_lease_expires_at.sql +5 -0
- brawny/db/migrations/015_add_signer_alias.sql +14 -0
- brawny/db/migrations/016_runtime_controls_and_quarantine.sql +32 -0
- brawny/db/migrations/017_add_job_drain.sql +6 -0
- brawny/db/migrations/018_add_nonce_reset_audit.sql +20 -0
- brawny/db/migrations/019_add_job_cooldowns.sql +8 -0
- brawny/db/migrations/020_attempt_unique_initial.sql +7 -0
- brawny/db/ops/__init__.py +3 -25
- brawny/db/ops/logs.py +1 -2
- brawny/db/queries.py +47 -91
- brawny/db/serialized.py +65 -0
- brawny/db/sqlite/__init__.py +1001 -0
- brawny/db/sqlite/connection.py +231 -0
- brawny/db/sqlite/execute.py +116 -0
- brawny/db/sqlite/mappers.py +190 -0
- brawny/db/sqlite/repos/attempts.py +372 -0
- brawny/db/sqlite/repos/block_state.py +102 -0
- brawny/db/sqlite/repos/cache.py +104 -0
- brawny/db/sqlite/repos/intents.py +1021 -0
- brawny/db/sqlite/repos/jobs.py +200 -0
- brawny/db/sqlite/repos/maintenance.py +182 -0
- brawny/db/sqlite/repos/signers_nonces.py +566 -0
- brawny/db/sqlite/tx.py +119 -0
- brawny/http.py +194 -0
- brawny/invariants.py +11 -24
- brawny/jobs/base.py +8 -0
- brawny/jobs/job_validation.py +2 -1
- brawny/keystore.py +83 -7
- brawny/lifecycle.py +64 -12
- brawny/logging.py +0 -2
- brawny/metrics.py +84 -12
- brawny/model/contexts.py +111 -9
- brawny/model/enums.py +1 -0
- brawny/model/errors.py +18 -0
- brawny/model/types.py +47 -131
- brawny/network_guard.py +133 -0
- brawny/networks/__init__.py +5 -5
- brawny/networks/config.py +1 -7
- brawny/networks/manager.py +14 -11
- brawny/runtime_controls.py +74 -0
- brawny/scheduler/poller.py +11 -7
- brawny/scheduler/reorg.py +95 -39
- brawny/scheduler/runner.py +442 -168
- brawny/scheduler/shutdown.py +3 -3
- brawny/script_tx.py +3 -3
- brawny/telegram.py +53 -7
- brawny/testing.py +1 -0
- brawny/timeout.py +38 -0
- brawny/tx/executor.py +922 -308
- brawny/tx/intent.py +54 -16
- brawny/tx/monitor.py +31 -12
- brawny/tx/nonce.py +212 -90
- brawny/tx/replacement.py +69 -18
- brawny/tx/retry_policy.py +24 -0
- brawny/tx/stages/types.py +75 -0
- brawny/types.py +18 -0
- brawny/utils.py +41 -0
- {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/METADATA +3 -3
- brawny-0.1.22.dist-info/RECORD +163 -0
- brawny/_rpc/manager.py +0 -982
- brawny/_rpc/selector.py +0 -156
- brawny/db/base_new.py +0 -165
- brawny/db/mappers.py +0 -182
- brawny/db/migrations/008_add_transactions.sql +0 -72
- brawny/db/ops/attempts.py +0 -108
- brawny/db/ops/blocks.py +0 -83
- brawny/db/ops/cache.py +0 -93
- brawny/db/ops/intents.py +0 -296
- brawny/db/ops/jobs.py +0 -110
- brawny/db/ops/nonces.py +0 -322
- brawny/db/postgres.py +0 -2535
- brawny/db/postgres_new.py +0 -196
- brawny/db/sqlite.py +0 -2733
- brawny/db/sqlite_new.py +0 -191
- brawny-0.1.13.dist-info/RECORD +0 -141
- {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/WHEEL +0 -0
- {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/entry_points.txt +0 -0
- {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/top_level.txt +0 -0
brawny/_rpc/errors.py
CHANGED
|
@@ -8,9 +8,211 @@ Error classification per SPEC:
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import asyncio
|
|
12
|
+
import enum
|
|
13
|
+
import socket
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
11
17
|
from brawny.model.errors import BrawnyError
|
|
12
18
|
|
|
13
19
|
|
|
20
|
+
class RpcErrorKind(enum.Enum):
|
|
21
|
+
"""Canonical error kinds for RPC classification."""
|
|
22
|
+
|
|
23
|
+
# Transport
|
|
24
|
+
TIMEOUT = "timeout"
|
|
25
|
+
DEADLINE_EXHAUSTED = "deadline_exhausted"
|
|
26
|
+
NETWORK = "network"
|
|
27
|
+
RATE_LIMIT = "rate_limit"
|
|
28
|
+
AUTH = "auth"
|
|
29
|
+
SERVER_ERROR = "server_error"
|
|
30
|
+
|
|
31
|
+
# JSON-RPC protocol
|
|
32
|
+
METHOD_NOT_FOUND = "method_not_found"
|
|
33
|
+
INVALID_PARAMS = "invalid_params"
|
|
34
|
+
BAD_REQUEST = "bad_request"
|
|
35
|
+
PARSE_ERROR = "parse_error"
|
|
36
|
+
|
|
37
|
+
# Execution
|
|
38
|
+
EXECUTION_REVERTED = "execution_reverted"
|
|
39
|
+
OUT_OF_GAS = "out_of_gas"
|
|
40
|
+
|
|
41
|
+
# TX rejection (fatal)
|
|
42
|
+
NONCE_TOO_LOW = "nonce_too_low"
|
|
43
|
+
NONCE_TOO_HIGH = "nonce_too_high"
|
|
44
|
+
INSUFFICIENT_FUNDS = "insufficient_funds"
|
|
45
|
+
INTRINSIC_GAS_TOO_LOW = "intrinsic_gas_too_low"
|
|
46
|
+
GAS_LIMIT_EXCEEDED = "gas_limit_exceeded"
|
|
47
|
+
TX_TYPE_NOT_SUPPORTED = "tx_type_not_supported"
|
|
48
|
+
ALREADY_KNOWN = "already_known"
|
|
49
|
+
|
|
50
|
+
# TX rejection (recoverable)
|
|
51
|
+
REPLACEMENT_UNDERPRICED = "replacement_underpriced"
|
|
52
|
+
TX_UNDERPRICED = "tx_underpriced"
|
|
53
|
+
MAX_FEE_TOO_LOW = "max_fee_too_low"
|
|
54
|
+
|
|
55
|
+
UNKNOWN = "unknown"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class RpcErrorInfo:
|
|
60
|
+
"""Structured classification result."""
|
|
61
|
+
|
|
62
|
+
kind: RpcErrorKind
|
|
63
|
+
retryable: bool
|
|
64
|
+
failover_ok: bool
|
|
65
|
+
message: str
|
|
66
|
+
code: int | None = None
|
|
67
|
+
http_status: int | None = None
|
|
68
|
+
provider: str | None = None
|
|
69
|
+
method: str | None = None
|
|
70
|
+
classification_source: str = "unknown"
|
|
71
|
+
deadline_remaining: float | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
ERROR_KIND_DEFAULTS: dict[RpcErrorKind, tuple[bool, bool]] = {
|
|
75
|
+
# Transport
|
|
76
|
+
RpcErrorKind.TIMEOUT: (True, True),
|
|
77
|
+
RpcErrorKind.DEADLINE_EXHAUSTED: (False, False),
|
|
78
|
+
RpcErrorKind.NETWORK: (True, True),
|
|
79
|
+
RpcErrorKind.RATE_LIMIT: (True, True),
|
|
80
|
+
RpcErrorKind.SERVER_ERROR: (True, True),
|
|
81
|
+
RpcErrorKind.AUTH: (False, False),
|
|
82
|
+
|
|
83
|
+
# Protocol
|
|
84
|
+
RpcErrorKind.METHOD_NOT_FOUND: (False, True),
|
|
85
|
+
RpcErrorKind.INVALID_PARAMS: (False, False),
|
|
86
|
+
RpcErrorKind.BAD_REQUEST: (False, True),
|
|
87
|
+
RpcErrorKind.PARSE_ERROR: (False, False),
|
|
88
|
+
|
|
89
|
+
# Execution
|
|
90
|
+
RpcErrorKind.EXECUTION_REVERTED: (False, False),
|
|
91
|
+
RpcErrorKind.OUT_OF_GAS: (False, False),
|
|
92
|
+
|
|
93
|
+
# TX fatal
|
|
94
|
+
RpcErrorKind.NONCE_TOO_LOW: (False, False),
|
|
95
|
+
RpcErrorKind.NONCE_TOO_HIGH: (False, False),
|
|
96
|
+
RpcErrorKind.INSUFFICIENT_FUNDS: (False, False),
|
|
97
|
+
RpcErrorKind.INTRINSIC_GAS_TOO_LOW: (False, False),
|
|
98
|
+
RpcErrorKind.GAS_LIMIT_EXCEEDED: (False, False),
|
|
99
|
+
RpcErrorKind.TX_TYPE_NOT_SUPPORTED: (False, False),
|
|
100
|
+
RpcErrorKind.ALREADY_KNOWN: (False, False),
|
|
101
|
+
|
|
102
|
+
# TX recoverable
|
|
103
|
+
RpcErrorKind.REPLACEMENT_UNDERPRICED: (False, False),
|
|
104
|
+
RpcErrorKind.TX_UNDERPRICED: (False, False),
|
|
105
|
+
RpcErrorKind.MAX_FEE_TOO_LOW: (False, False),
|
|
106
|
+
|
|
107
|
+
# Unknown - retry same endpoint only; failover only when transport is clear.
|
|
108
|
+
RpcErrorKind.UNKNOWN: (True, False),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class ExtractedError:
|
|
114
|
+
"""Raw extracted error data."""
|
|
115
|
+
|
|
116
|
+
http_status: int | None = None
|
|
117
|
+
jsonrpc_code: int | None = None
|
|
118
|
+
jsonrpc_message: str | None = None
|
|
119
|
+
jsonrpc_data: Any = None
|
|
120
|
+
exception_type: str = ""
|
|
121
|
+
exception_message: str = ""
|
|
122
|
+
provider_hint: str | None = None
|
|
123
|
+
is_timeout: bool = False
|
|
124
|
+
is_connection_error: bool = False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def extract_rpc_error(exc: Exception, endpoint: str | None = None) -> ExtractedError:
|
|
128
|
+
"""Extract structured error data from exception.
|
|
129
|
+
|
|
130
|
+
IMPORTANT: CancelledError must NOT be passed here - let it propagate.
|
|
131
|
+
"""
|
|
132
|
+
if isinstance(exc, asyncio.CancelledError):
|
|
133
|
+
raise exc
|
|
134
|
+
|
|
135
|
+
result = ExtractedError(
|
|
136
|
+
exception_type=f"{type(exc).__module__}.{type(exc).__name__}",
|
|
137
|
+
exception_message=str(exc)[:500],
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if isinstance(exc, (asyncio.TimeoutError, TimeoutError, socket.timeout)):
|
|
141
|
+
result.is_timeout = True
|
|
142
|
+
|
|
143
|
+
if isinstance(exc, (ConnectionError, ConnectionRefusedError, ConnectionResetError)):
|
|
144
|
+
result.is_connection_error = True
|
|
145
|
+
if isinstance(exc, OSError) and exc.errno in (111, 104, 32):
|
|
146
|
+
result.is_connection_error = True
|
|
147
|
+
|
|
148
|
+
for arg in getattr(exc, "args", []):
|
|
149
|
+
if isinstance(arg, dict):
|
|
150
|
+
_extract_from_dict(arg, result)
|
|
151
|
+
elif isinstance(arg, str):
|
|
152
|
+
_try_parse_json(arg, result)
|
|
153
|
+
|
|
154
|
+
response = getattr(exc, "response", None)
|
|
155
|
+
if response is not None:
|
|
156
|
+
result.http_status = getattr(response, "status_code", None)
|
|
157
|
+
_try_parse_json(getattr(response, "text", ""), result)
|
|
158
|
+
|
|
159
|
+
if hasattr(exc, "status"):
|
|
160
|
+
result.http_status = getattr(exc, "status", None)
|
|
161
|
+
|
|
162
|
+
for attr in ("body", "text", "error"):
|
|
163
|
+
val = getattr(exc, attr, None)
|
|
164
|
+
if val is None:
|
|
165
|
+
continue
|
|
166
|
+
if isinstance(val, str):
|
|
167
|
+
_try_parse_json(val, result)
|
|
168
|
+
elif isinstance(val, dict):
|
|
169
|
+
_extract_from_dict(val, result)
|
|
170
|
+
|
|
171
|
+
if endpoint:
|
|
172
|
+
result.provider_hint = _infer_provider(endpoint)
|
|
173
|
+
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _extract_from_dict(data: dict, result: ExtractedError) -> None:
|
|
178
|
+
if "code" in data and "message" in data:
|
|
179
|
+
result.jsonrpc_code = data.get("code")
|
|
180
|
+
result.jsonrpc_message = data.get("message")
|
|
181
|
+
result.jsonrpc_data = data.get("data")
|
|
182
|
+
return
|
|
183
|
+
error = data.get("error")
|
|
184
|
+
if isinstance(error, dict):
|
|
185
|
+
result.jsonrpc_code = error.get("code")
|
|
186
|
+
result.jsonrpc_message = error.get("message")
|
|
187
|
+
result.jsonrpc_data = error.get("data")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _try_parse_json(s: str, result: ExtractedError) -> None:
|
|
191
|
+
import json
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
data = json.loads(s)
|
|
195
|
+
if isinstance(data, dict):
|
|
196
|
+
_extract_from_dict(data, result)
|
|
197
|
+
except (json.JSONDecodeError, ValueError):
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _infer_provider(endpoint: str) -> str | None:
|
|
202
|
+
e = endpoint.lower()
|
|
203
|
+
if "alchemy" in e:
|
|
204
|
+
return "alchemy"
|
|
205
|
+
if "infura" in e:
|
|
206
|
+
return "infura"
|
|
207
|
+
if "quicknode" in e:
|
|
208
|
+
return "quicknode"
|
|
209
|
+
if "ankr" in e:
|
|
210
|
+
return "ankr"
|
|
211
|
+
if "localhost" in e or "127.0.0.1" in e:
|
|
212
|
+
return "local"
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
14
216
|
class RPCError(BrawnyError):
|
|
15
217
|
"""Base RPC error."""
|
|
16
218
|
|
|
@@ -37,6 +239,24 @@ class RPCRetryableError(RPCError):
|
|
|
37
239
|
pass
|
|
38
240
|
|
|
39
241
|
|
|
242
|
+
class RPCTransient(RPCRetryableError):
|
|
243
|
+
"""Transient RPC error (timeouts, network, server errors)."""
|
|
244
|
+
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class RPCRateLimited(RPCRetryableError):
|
|
249
|
+
"""Rate-limited RPC error (HTTP 429 / provider throttling)."""
|
|
250
|
+
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class RPCDeadlineExceeded(RPCError):
|
|
255
|
+
"""RPC deadline exhausted before call could be executed."""
|
|
256
|
+
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
|
|
40
260
|
class RPCFatalError(RPCError):
|
|
41
261
|
"""Fatal RPC error that should not be retried.
|
|
42
262
|
|
|
@@ -47,6 +267,18 @@ class RPCFatalError(RPCError):
|
|
|
47
267
|
pass
|
|
48
268
|
|
|
49
269
|
|
|
270
|
+
class RPCPermanent(RPCFatalError):
|
|
271
|
+
"""Permanent RPC error (auth, invalid params, method not found)."""
|
|
272
|
+
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class RPCDecode(RPCFatalError):
|
|
277
|
+
"""RPC decode error (malformed JSON or invalid response)."""
|
|
278
|
+
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
|
|
50
282
|
class RPCRecoverableError(RPCError):
|
|
51
283
|
"""RPC error that may succeed with different parameters.
|
|
52
284
|
|
|
@@ -59,7 +291,7 @@ class RPCRecoverableError(RPCError):
|
|
|
59
291
|
class RPCPoolExhaustedError(RPCError):
|
|
60
292
|
"""All endpoints in a pool failed (internal, group-agnostic).
|
|
61
293
|
|
|
62
|
-
This is raised
|
|
294
|
+
This is raised when all endpoints fail during an operation.
|
|
63
295
|
It does not include group context - the caller (broadcast layer) wraps
|
|
64
296
|
this into RPCGroupUnavailableError with group context.
|
|
65
297
|
"""
|
|
@@ -176,7 +408,7 @@ def classify_error(
|
|
|
176
408
|
http_status: int | None = None,
|
|
177
409
|
rpc_code: int | None = None,
|
|
178
410
|
) -> type[RPCError]:
|
|
179
|
-
"""Classify an error into
|
|
411
|
+
"""Classify an error into a coarse RPC error taxonomy.
|
|
180
412
|
|
|
181
413
|
Args:
|
|
182
414
|
error: The exception to classify
|
|
@@ -189,12 +421,14 @@ def classify_error(
|
|
|
189
421
|
error_msg = str(error).lower()
|
|
190
422
|
|
|
191
423
|
# Check HTTP status first
|
|
424
|
+
if http_status == 429:
|
|
425
|
+
return RPCRateLimited
|
|
192
426
|
if http_status and http_status in RETRYABLE_HTTP_STATUS:
|
|
193
|
-
return
|
|
427
|
+
return RPCTransient
|
|
194
428
|
|
|
195
429
|
# Check JSON-RPC error code
|
|
196
430
|
if rpc_code and rpc_code in RETRYABLE_RPC_CODES:
|
|
197
|
-
return
|
|
431
|
+
return RPCTransient
|
|
198
432
|
|
|
199
433
|
# Check for recoverable TX errors (check before fatal)
|
|
200
434
|
for substring in RECOVERABLE_TX_SUBSTRINGS:
|
|
@@ -208,11 +442,11 @@ def classify_error(
|
|
|
208
442
|
|
|
209
443
|
# Check common error patterns
|
|
210
444
|
if "timeout" in error_msg or "timed out" in error_msg:
|
|
211
|
-
return
|
|
445
|
+
return RPCTransient
|
|
212
446
|
if "connection" in error_msg:
|
|
213
|
-
return
|
|
447
|
+
return RPCTransient
|
|
214
448
|
if "rate limit" in error_msg:
|
|
215
|
-
return
|
|
449
|
+
return RPCRateLimited
|
|
216
450
|
if "reverted" in error_msg:
|
|
217
451
|
return RPCFatalError
|
|
218
452
|
if "nonce" in error_msg and ("low" in error_msg or "invalid" in error_msg):
|
|
@@ -221,32 +455,232 @@ def classify_error(
|
|
|
221
455
|
return RPCFatalError
|
|
222
456
|
|
|
223
457
|
# Default to retryable for unknown errors
|
|
224
|
-
return
|
|
458
|
+
return RPCTransient
|
|
225
459
|
|
|
226
460
|
|
|
227
|
-
|
|
228
|
-
|
|
461
|
+
ERROR_SELECTOR = "0x08c379a0"
|
|
462
|
+
PANIC_SELECTOR = "0x4e487b71"
|
|
229
463
|
|
|
230
|
-
Args:
|
|
231
|
-
error: The exception to normalize
|
|
232
|
-
|
|
233
|
-
Returns:
|
|
234
|
-
Normalized error code string
|
|
235
|
-
"""
|
|
236
|
-
error_msg = str(error).lower()
|
|
237
|
-
|
|
238
|
-
# Check known patterns
|
|
239
|
-
for code in FATAL_TX_ERROR_CODES:
|
|
240
|
-
if code.replace("_", " ") in error_msg or code.replace("_", "") in error_msg:
|
|
241
|
-
return code
|
|
242
464
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
465
|
+
def classify_rpc_error(
|
|
466
|
+
exc: Exception,
|
|
467
|
+
endpoint: str | None = None,
|
|
468
|
+
method: str | None = None,
|
|
469
|
+
deadline: "Any | None" = None,
|
|
470
|
+
) -> RpcErrorInfo:
|
|
471
|
+
"""Classify RPC error with structured extraction and metrics-ready info.
|
|
246
472
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
473
|
+
IMPORTANT: CancelledError must NOT be passed here - let it propagate.
|
|
474
|
+
"""
|
|
475
|
+
if isinstance(exc, asyncio.CancelledError):
|
|
476
|
+
raise exc
|
|
477
|
+
|
|
478
|
+
if isinstance(exc, RPCDeadlineExceeded):
|
|
479
|
+
info = _make_info(RpcErrorKind.DEADLINE_EXHAUSTED, ExtractedError(), "deadline_exhausted")
|
|
480
|
+
info.method = method
|
|
481
|
+
info.deadline_remaining = 0.0
|
|
482
|
+
return info
|
|
483
|
+
|
|
484
|
+
extracted = extract_rpc_error(exc, endpoint)
|
|
485
|
+
info: RpcErrorInfo | None = None
|
|
486
|
+
|
|
487
|
+
info = _classify_revert_first(extracted, method)
|
|
488
|
+
|
|
489
|
+
if info is None and extracted.http_status is not None:
|
|
490
|
+
info = _classify_by_http_status(extracted)
|
|
491
|
+
if info is None and extracted.jsonrpc_code is not None:
|
|
492
|
+
info = _classify_by_jsonrpc_code(extracted, method)
|
|
493
|
+
if info is None:
|
|
494
|
+
info = _classify_by_provider_patterns(extracted)
|
|
495
|
+
if info is None:
|
|
496
|
+
info = _classify_by_structured(extracted, method)
|
|
497
|
+
if info is None:
|
|
498
|
+
info = _classify_by_heuristics(extracted, method)
|
|
499
|
+
|
|
500
|
+
info.method = method
|
|
501
|
+
info.provider = extracted.provider_hint
|
|
502
|
+
info.http_status = extracted.http_status
|
|
503
|
+
info.code = extracted.jsonrpc_code
|
|
504
|
+
if deadline is not None and hasattr(deadline, "remaining"):
|
|
505
|
+
info.deadline_remaining = deadline.remaining()
|
|
506
|
+
|
|
507
|
+
return info
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _classify_by_http_status(ext: ExtractedError) -> RpcErrorInfo | None:
|
|
511
|
+
status = ext.http_status
|
|
512
|
+
if status == 429:
|
|
513
|
+
return _make_info(RpcErrorKind.RATE_LIMIT, ext, "http_429")
|
|
514
|
+
if status in (401, 403):
|
|
515
|
+
return _make_info(RpcErrorKind.AUTH, ext, "http_auth")
|
|
516
|
+
if status == 400:
|
|
517
|
+
if ext.jsonrpc_code == -32602:
|
|
518
|
+
return _make_info(RpcErrorKind.INVALID_PARAMS, ext, "http_400_jsonrpc")
|
|
519
|
+
return _make_info(RpcErrorKind.BAD_REQUEST, ext, "http_400")
|
|
520
|
+
if status == 502:
|
|
521
|
+
return _make_info(RpcErrorKind.NETWORK, ext, "http_502")
|
|
522
|
+
if status == 503:
|
|
523
|
+
return _make_info(RpcErrorKind.SERVER_ERROR, ext, "http_503")
|
|
524
|
+
if status == 504:
|
|
525
|
+
return _make_info(RpcErrorKind.TIMEOUT, ext, "http_504")
|
|
526
|
+
if status is not None and status >= 500:
|
|
527
|
+
return _make_info(RpcErrorKind.SERVER_ERROR, ext, "http_5xx")
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _classify_by_jsonrpc_code(ext: ExtractedError, method: str | None) -> RpcErrorInfo | None:
|
|
532
|
+
code = ext.jsonrpc_code
|
|
533
|
+
|
|
534
|
+
if code == -32700:
|
|
535
|
+
return _make_info(RpcErrorKind.PARSE_ERROR, ext, "jsonrpc_-32700")
|
|
536
|
+
if code == -32600:
|
|
537
|
+
return _make_info(RpcErrorKind.BAD_REQUEST, ext, "jsonrpc_-32600")
|
|
538
|
+
if code == -32601:
|
|
539
|
+
return _make_info(RpcErrorKind.METHOD_NOT_FOUND, ext, "jsonrpc_-32601")
|
|
540
|
+
if code == -32602:
|
|
541
|
+
return _make_info(RpcErrorKind.INVALID_PARAMS, ext, "jsonrpc_-32602")
|
|
542
|
+
if code == -32603:
|
|
543
|
+
return _make_info(RpcErrorKind.SERVER_ERROR, ext, "jsonrpc_-32603")
|
|
544
|
+
|
|
545
|
+
if code == 3:
|
|
546
|
+
if method in ("eth_call", "eth_estimateGas"):
|
|
547
|
+
return _make_info(RpcErrorKind.EXECUTION_REVERTED, ext, "jsonrpc_3")
|
|
548
|
+
return _make_info(RpcErrorKind.SERVER_ERROR, ext, "jsonrpc_3_send")
|
|
549
|
+
|
|
550
|
+
if code is not None and -32099 <= code <= -32000:
|
|
551
|
+
return _classify_server_error_message(ext, method)
|
|
552
|
+
|
|
553
|
+
return None
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _classify_server_error_message(ext: ExtractedError, method: str | None) -> RpcErrorInfo | None:
|
|
557
|
+
msg = (ext.jsonrpc_message or "").lower()
|
|
558
|
+
|
|
559
|
+
if "nonce too low" in msg:
|
|
560
|
+
return _make_info(RpcErrorKind.NONCE_TOO_LOW, ext, "msg_nonce_low")
|
|
561
|
+
if "nonce too high" in msg:
|
|
562
|
+
return _make_info(RpcErrorKind.NONCE_TOO_HIGH, ext, "msg_nonce_high")
|
|
563
|
+
if "insufficient funds" in msg:
|
|
564
|
+
return _make_info(RpcErrorKind.INSUFFICIENT_FUNDS, ext, "msg_funds")
|
|
565
|
+
if "intrinsic gas too low" in msg:
|
|
566
|
+
return _make_info(RpcErrorKind.INTRINSIC_GAS_TOO_LOW, ext, "msg_intrinsic_gas")
|
|
567
|
+
if "gas limit" in msg and "exceeded" in msg:
|
|
568
|
+
return _make_info(RpcErrorKind.GAS_LIMIT_EXCEEDED, ext, "msg_gas_limit")
|
|
569
|
+
if "out of gas" in msg:
|
|
570
|
+
return _make_info(RpcErrorKind.OUT_OF_GAS, ext, "msg_out_of_gas")
|
|
571
|
+
if "already known" in msg or "known transaction" in msg:
|
|
572
|
+
return _make_info(RpcErrorKind.ALREADY_KNOWN, ext, "msg_already_known")
|
|
573
|
+
if "replacement transaction underpriced" in msg:
|
|
574
|
+
return _make_info(RpcErrorKind.REPLACEMENT_UNDERPRICED, ext, "msg_replacement")
|
|
575
|
+
if "underpriced" in msg:
|
|
576
|
+
return _make_info(RpcErrorKind.TX_UNDERPRICED, ext, "msg_underpriced")
|
|
577
|
+
|
|
578
|
+
if "execution reverted" in msg or "reverted" in msg:
|
|
579
|
+
if method in ("eth_call", "eth_estimateGas"):
|
|
580
|
+
return _make_info(RpcErrorKind.EXECUTION_REVERTED, ext, "msg_reverted")
|
|
581
|
+
return _make_info(RpcErrorKind.SERVER_ERROR, ext, "msg_reverted_send")
|
|
582
|
+
|
|
583
|
+
return _make_info(RpcErrorKind.SERVER_ERROR, ext, "jsonrpc_-32000_generic")
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _classify_by_provider_patterns(ext: ExtractedError) -> RpcErrorInfo | None:
|
|
587
|
+
code = ext.jsonrpc_code
|
|
588
|
+
msg = (ext.jsonrpc_message or "").lower()
|
|
589
|
+
|
|
590
|
+
if ext.provider_hint == "alchemy" and code == 429:
|
|
591
|
+
return _make_info(RpcErrorKind.RATE_LIMIT, ext, "alchemy_429")
|
|
592
|
+
|
|
593
|
+
if code == -32005 and any(kw in msg for kw in ("exceeded", "limit", "rate", "requests")):
|
|
594
|
+
return _make_info(RpcErrorKind.RATE_LIMIT, ext, "infura_-32005")
|
|
595
|
+
|
|
596
|
+
return None
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _classify_by_structured(ext: ExtractedError, method: str | None) -> RpcErrorInfo | None:
|
|
600
|
+
if ext.exception_type.endswith(("ContractLogicError", "ContractCustomError")):
|
|
601
|
+
if method in ("eth_call", "eth_estimateGas"):
|
|
602
|
+
return _make_info(RpcErrorKind.EXECUTION_REVERTED, ext, "web3_revert_exception")
|
|
603
|
+
|
|
604
|
+
if ext.is_timeout:
|
|
605
|
+
return _make_info(RpcErrorKind.TIMEOUT, ext, "flag_timeout")
|
|
606
|
+
if ext.is_connection_error:
|
|
607
|
+
return _make_info(RpcErrorKind.NETWORK, ext, "flag_connection")
|
|
608
|
+
|
|
609
|
+
data = ext.jsonrpc_data
|
|
610
|
+
if isinstance(data, str) and data.startswith("0x") and len(data) >= 10:
|
|
611
|
+
selector = data[:10]
|
|
612
|
+
if selector in (ERROR_SELECTOR, PANIC_SELECTOR):
|
|
613
|
+
if method in ("eth_call", "eth_estimateGas"):
|
|
614
|
+
return _make_info(RpcErrorKind.EXECUTION_REVERTED, ext, "revert_data")
|
|
615
|
+
if isinstance(data, dict):
|
|
616
|
+
inner = data.get("data") or data.get("result") or ""
|
|
617
|
+
if isinstance(inner, str) and inner.startswith("0x") and len(inner) >= 10:
|
|
618
|
+
if inner[:10] in (ERROR_SELECTOR, PANIC_SELECTOR):
|
|
619
|
+
if method in ("eth_call", "eth_estimateGas"):
|
|
620
|
+
return _make_info(RpcErrorKind.EXECUTION_REVERTED, ext, "revert_data_nested")
|
|
621
|
+
|
|
622
|
+
return None
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _classify_by_heuristics(ext: ExtractedError, method: str | None) -> RpcErrorInfo:
|
|
626
|
+
msg = " ".join((ext.jsonrpc_message or ext.exception_message or "").lower().split())
|
|
627
|
+
heuristics = [
|
|
628
|
+
("nonce_low", ["nonce too low"], RpcErrorKind.NONCE_TOO_LOW),
|
|
629
|
+
("funds", ["insufficient funds", "insufficient balance"], RpcErrorKind.INSUFFICIENT_FUNDS),
|
|
630
|
+
("already_known", ["already known", "known transaction"], RpcErrorKind.ALREADY_KNOWN),
|
|
631
|
+
("replacement", ["replacement transaction underpriced"], RpcErrorKind.REPLACEMENT_UNDERPRICED),
|
|
632
|
+
("underpriced", ["underpriced"], RpcErrorKind.TX_UNDERPRICED),
|
|
633
|
+
("reverted", ["execution reverted", "reverted"], RpcErrorKind.EXECUTION_REVERTED),
|
|
634
|
+
("timeout", ["timeout", "timed out"], RpcErrorKind.TIMEOUT),
|
|
635
|
+
("rate_limit", ["rate limit", "too many requests"], RpcErrorKind.RATE_LIMIT),
|
|
636
|
+
("connection", ["connection refused", "connection reset"], RpcErrorKind.NETWORK),
|
|
637
|
+
]
|
|
638
|
+
|
|
639
|
+
for name, patterns, kind in heuristics:
|
|
640
|
+
if any(p in msg for p in patterns):
|
|
641
|
+
if kind == RpcErrorKind.EXECUTION_REVERTED and method not in ("eth_call", "eth_estimateGas"):
|
|
642
|
+
return _make_info(RpcErrorKind.SERVER_ERROR, ext, "heuristic_reverted_send")
|
|
643
|
+
return _make_info(kind, ext, f"heuristic_{name}")
|
|
644
|
+
|
|
645
|
+
return _make_info(RpcErrorKind.UNKNOWN, ext, "no_match")
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _classify_revert_first(ext: ExtractedError, method: str | None) -> RpcErrorInfo | None:
|
|
649
|
+
if method not in ("eth_call", "eth_estimateGas"):
|
|
650
|
+
return None
|
|
651
|
+
|
|
652
|
+
if ext.exception_type.endswith(("ContractLogicError", "ContractCustomError")):
|
|
653
|
+
return _make_info(RpcErrorKind.EXECUTION_REVERTED, ext, "revert_exception")
|
|
654
|
+
|
|
655
|
+
msg = (ext.jsonrpc_message or "").lower()
|
|
656
|
+
if "execution reverted" in msg or msg == "reverted":
|
|
657
|
+
return _make_info(RpcErrorKind.EXECUTION_REVERTED, ext, "revert_message")
|
|
658
|
+
|
|
659
|
+
if ext.jsonrpc_code == 3:
|
|
660
|
+
return _make_info(RpcErrorKind.EXECUTION_REVERTED, ext, "revert_code")
|
|
661
|
+
|
|
662
|
+
data = ext.jsonrpc_data
|
|
663
|
+
if isinstance(data, str) and data.startswith("0x") and len(data) >= 10:
|
|
664
|
+
if data[:10] in (ERROR_SELECTOR, PANIC_SELECTOR):
|
|
665
|
+
return _make_info(RpcErrorKind.EXECUTION_REVERTED, ext, "revert_data")
|
|
666
|
+
if isinstance(data, dict):
|
|
667
|
+
inner = data.get("data") or data.get("result") or ""
|
|
668
|
+
if isinstance(inner, str) and inner.startswith("0x") and len(inner) >= 10:
|
|
669
|
+
if inner[:10] in (ERROR_SELECTOR, PANIC_SELECTOR):
|
|
670
|
+
return _make_info(RpcErrorKind.EXECUTION_REVERTED, ext, "revert_data_nested")
|
|
671
|
+
|
|
672
|
+
return None
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _make_info(kind: RpcErrorKind, ext: ExtractedError, source: str) -> RpcErrorInfo:
|
|
676
|
+
retryable, failover_ok = ERROR_KIND_DEFAULTS.get(kind, (True, True))
|
|
677
|
+
return RpcErrorInfo(
|
|
678
|
+
kind=kind,
|
|
679
|
+
retryable=retryable,
|
|
680
|
+
failover_ok=failover_ok,
|
|
681
|
+
message=ext.jsonrpc_message or ext.exception_message or "",
|
|
682
|
+
code=ext.jsonrpc_code,
|
|
683
|
+
http_status=ext.http_status,
|
|
684
|
+
provider=ext.provider_hint,
|
|
685
|
+
classification_source=source,
|
|
686
|
+
)
|
brawny/_rpc/gas.py
CHANGED
|
@@ -21,8 +21,9 @@ from concurrent.futures import ThreadPoolExecutor
|
|
|
21
21
|
from dataclasses import dataclass
|
|
22
22
|
from typing import TYPE_CHECKING
|
|
23
23
|
|
|
24
|
+
from brawny.timeout import Deadline
|
|
24
25
|
if TYPE_CHECKING:
|
|
25
|
-
from brawny._rpc.
|
|
26
|
+
from brawny._rpc.clients import ReadClient
|
|
26
27
|
|
|
27
28
|
# Bounded executor for async wrappers (prevents thread starvation)
|
|
28
29
|
_GAS_EXECUTOR = ThreadPoolExecutor(max_workers=4, thread_name_prefix="gas_rpc")
|
|
@@ -49,7 +50,7 @@ class GasQuoteCache:
|
|
|
49
50
|
No TTL needed since we fetch latest block each call.
|
|
50
51
|
"""
|
|
51
52
|
|
|
52
|
-
def __init__(self, rpc: "
|
|
53
|
+
def __init__(self, rpc: "ReadClient", ttl_seconds: int = 15) -> None:
|
|
53
54
|
"""Initialize gas cache.
|
|
54
55
|
|
|
55
56
|
Args:
|
|
@@ -114,19 +115,19 @@ class GasQuoteCache:
|
|
|
114
115
|
self._cache = quote
|
|
115
116
|
return quote
|
|
116
117
|
|
|
117
|
-
def get_quote_sync(self) -> GasQuote | None:
|
|
118
|
+
def get_quote_sync(self, deadline: "Deadline | None" = None) -> GasQuote | None:
|
|
118
119
|
"""Get cached quote if available (non-blocking, for executor).
|
|
119
120
|
|
|
120
121
|
Returns cached quote without checking block freshness.
|
|
121
122
|
Caller should be aware this may be from a previous block.
|
|
122
123
|
"""
|
|
123
124
|
if self._cache is None:
|
|
124
|
-
self._cache = self._fetch_quote_sync()
|
|
125
|
+
self._cache = self._fetch_quote_sync(deadline)
|
|
125
126
|
return self._cache
|
|
126
127
|
|
|
127
|
-
def _fetch_quote_sync(self) -> GasQuote:
|
|
128
|
+
def _fetch_quote_sync(self, deadline: "Deadline | None") -> GasQuote:
|
|
128
129
|
"""Fetch quote synchronously from RPC."""
|
|
129
|
-
block = self._rpc.get_block("latest")
|
|
130
|
+
block = self._rpc.get_block("latest", deadline=deadline)
|
|
130
131
|
|
|
131
132
|
block_number = block.get("number", 0)
|
|
132
133
|
if isinstance(block_number, str):
|
brawny/_rpc/pool.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EndpointPool:
|
|
5
|
+
"""Endpoint pool with deterministic ordering only."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, endpoints: list[str]) -> None:
|
|
8
|
+
cleaned = [ep.strip() for ep in endpoints if ep and ep.strip()]
|
|
9
|
+
if not cleaned:
|
|
10
|
+
raise ValueError("At least one non-empty endpoint is required")
|
|
11
|
+
self._endpoints = cleaned
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def endpoints(self) -> list[str]:
|
|
15
|
+
return list(self._endpoints)
|
|
16
|
+
|
|
17
|
+
def order_endpoints(self) -> list[str]:
|
|
18
|
+
return list(self._endpoints)
|