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.
Files changed (135) hide show
  1. brawny/__init__.py +2 -0
  2. brawny/_context.py +5 -5
  3. brawny/_rpc/__init__.py +36 -12
  4. brawny/_rpc/broadcast.py +14 -13
  5. brawny/_rpc/caller.py +243 -0
  6. brawny/_rpc/client.py +539 -0
  7. brawny/_rpc/clients.py +11 -11
  8. brawny/_rpc/context.py +23 -0
  9. brawny/_rpc/errors.py +465 -31
  10. brawny/_rpc/gas.py +7 -6
  11. brawny/_rpc/pool.py +18 -0
  12. brawny/_rpc/retry.py +266 -0
  13. brawny/_rpc/retry_policy.py +81 -0
  14. brawny/accounts.py +28 -9
  15. brawny/alerts/__init__.py +15 -18
  16. brawny/alerts/abi_resolver.py +212 -36
  17. brawny/alerts/base.py +2 -2
  18. brawny/alerts/contracts.py +77 -10
  19. brawny/alerts/errors.py +30 -3
  20. brawny/alerts/events.py +38 -5
  21. brawny/alerts/health.py +19 -13
  22. brawny/alerts/send.py +513 -55
  23. brawny/api.py +39 -11
  24. brawny/assets/AGENTS.md +325 -0
  25. brawny/async_runtime.py +48 -0
  26. brawny/chain.py +3 -3
  27. brawny/cli/commands/__init__.py +2 -0
  28. brawny/cli/commands/console.py +69 -19
  29. brawny/cli/commands/contract.py +2 -2
  30. brawny/cli/commands/controls.py +121 -0
  31. brawny/cli/commands/health.py +2 -2
  32. brawny/cli/commands/job_dev.py +6 -5
  33. brawny/cli/commands/jobs.py +99 -2
  34. brawny/cli/commands/maintenance.py +13 -29
  35. brawny/cli/commands/migrate.py +1 -0
  36. brawny/cli/commands/run.py +10 -3
  37. brawny/cli/commands/script.py +8 -3
  38. brawny/cli/commands/signer.py +143 -26
  39. brawny/cli/helpers.py +0 -3
  40. brawny/cli_templates.py +25 -349
  41. brawny/config/__init__.py +4 -1
  42. brawny/config/models.py +43 -57
  43. brawny/config/parser.py +268 -57
  44. brawny/config/validation.py +52 -15
  45. brawny/daemon/context.py +4 -2
  46. brawny/daemon/core.py +185 -63
  47. brawny/daemon/loops.py +166 -98
  48. brawny/daemon/supervisor.py +261 -0
  49. brawny/db/__init__.py +14 -26
  50. brawny/db/base.py +248 -151
  51. brawny/db/global_cache.py +11 -1
  52. brawny/db/migrate.py +175 -28
  53. brawny/db/migrations/001_init.sql +4 -3
  54. brawny/db/migrations/010_add_nonce_gap_index.sql +1 -1
  55. brawny/db/migrations/011_add_job_logs.sql +1 -2
  56. brawny/db/migrations/012_add_claimed_by.sql +2 -2
  57. brawny/db/migrations/013_attempt_unique.sql +10 -0
  58. brawny/db/migrations/014_add_lease_expires_at.sql +5 -0
  59. brawny/db/migrations/015_add_signer_alias.sql +14 -0
  60. brawny/db/migrations/016_runtime_controls_and_quarantine.sql +32 -0
  61. brawny/db/migrations/017_add_job_drain.sql +6 -0
  62. brawny/db/migrations/018_add_nonce_reset_audit.sql +20 -0
  63. brawny/db/migrations/019_add_job_cooldowns.sql +8 -0
  64. brawny/db/migrations/020_attempt_unique_initial.sql +7 -0
  65. brawny/db/ops/__init__.py +3 -25
  66. brawny/db/ops/logs.py +1 -2
  67. brawny/db/queries.py +47 -91
  68. brawny/db/serialized.py +65 -0
  69. brawny/db/sqlite/__init__.py +1001 -0
  70. brawny/db/sqlite/connection.py +231 -0
  71. brawny/db/sqlite/execute.py +116 -0
  72. brawny/db/sqlite/mappers.py +190 -0
  73. brawny/db/sqlite/repos/attempts.py +372 -0
  74. brawny/db/sqlite/repos/block_state.py +102 -0
  75. brawny/db/sqlite/repos/cache.py +104 -0
  76. brawny/db/sqlite/repos/intents.py +1021 -0
  77. brawny/db/sqlite/repos/jobs.py +200 -0
  78. brawny/db/sqlite/repos/maintenance.py +182 -0
  79. brawny/db/sqlite/repos/signers_nonces.py +566 -0
  80. brawny/db/sqlite/tx.py +119 -0
  81. brawny/http.py +194 -0
  82. brawny/invariants.py +11 -24
  83. brawny/jobs/base.py +8 -0
  84. brawny/jobs/job_validation.py +2 -1
  85. brawny/keystore.py +83 -7
  86. brawny/lifecycle.py +64 -12
  87. brawny/logging.py +0 -2
  88. brawny/metrics.py +84 -12
  89. brawny/model/contexts.py +111 -9
  90. brawny/model/enums.py +1 -0
  91. brawny/model/errors.py +18 -0
  92. brawny/model/types.py +47 -131
  93. brawny/network_guard.py +133 -0
  94. brawny/networks/__init__.py +5 -5
  95. brawny/networks/config.py +1 -7
  96. brawny/networks/manager.py +14 -11
  97. brawny/runtime_controls.py +74 -0
  98. brawny/scheduler/poller.py +11 -7
  99. brawny/scheduler/reorg.py +95 -39
  100. brawny/scheduler/runner.py +442 -168
  101. brawny/scheduler/shutdown.py +3 -3
  102. brawny/script_tx.py +3 -3
  103. brawny/telegram.py +53 -7
  104. brawny/testing.py +1 -0
  105. brawny/timeout.py +38 -0
  106. brawny/tx/executor.py +922 -308
  107. brawny/tx/intent.py +54 -16
  108. brawny/tx/monitor.py +31 -12
  109. brawny/tx/nonce.py +212 -90
  110. brawny/tx/replacement.py +69 -18
  111. brawny/tx/retry_policy.py +24 -0
  112. brawny/tx/stages/types.py +75 -0
  113. brawny/types.py +18 -0
  114. brawny/utils.py +41 -0
  115. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/METADATA +3 -3
  116. brawny-0.1.22.dist-info/RECORD +163 -0
  117. brawny/_rpc/manager.py +0 -982
  118. brawny/_rpc/selector.py +0 -156
  119. brawny/db/base_new.py +0 -165
  120. brawny/db/mappers.py +0 -182
  121. brawny/db/migrations/008_add_transactions.sql +0 -72
  122. brawny/db/ops/attempts.py +0 -108
  123. brawny/db/ops/blocks.py +0 -83
  124. brawny/db/ops/cache.py +0 -93
  125. brawny/db/ops/intents.py +0 -296
  126. brawny/db/ops/jobs.py +0 -110
  127. brawny/db/ops/nonces.py +0 -322
  128. brawny/db/postgres.py +0 -2535
  129. brawny/db/postgres_new.py +0 -196
  130. brawny/db/sqlite.py +0 -2733
  131. brawny/db/sqlite_new.py +0 -191
  132. brawny-0.1.13.dist-info/RECORD +0 -141
  133. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/WHEEL +0 -0
  134. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/entry_points.txt +0 -0
  135. {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 by RPCManager when all endpoints fail during an operation.
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 RPCRetryableError, RPCFatalError, or RPCRecoverableError.
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 RPCRetryableError
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 RPCRetryableError
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 RPCRetryableError
445
+ return RPCTransient
212
446
  if "connection" in error_msg:
213
- return RPCRetryableError
447
+ return RPCTransient
214
448
  if "rate limit" in error_msg:
215
- return RPCRetryableError
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 RPCRetryableError
458
+ return RPCTransient
225
459
 
226
460
 
227
- def normalize_error_code(error: Exception) -> str:
228
- """Extract a normalized error code from an exception.
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
- for code in RECOVERABLE_TX_ERROR_CODES:
244
- if code.replace("_", " ") in error_msg or code.replace("_", "") in error_msg:
245
- return code
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
- for code in RETRYABLE_ERROR_CODES:
248
- if code.replace("_", " ") in error_msg or code.replace("_", "") in error_msg:
249
- return code
250
-
251
- # Generic fallback
252
- return "unknown_error"
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.manager import RPCManager
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: "RPCManager", ttl_seconds: int = 15) -> None:
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)