@reyaxyz/common 0.365.3 → 0.366.0

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,85 @@
1
+ import { errorChain } from './error-chain';
2
+
3
+ const TRANSIENT_HTTP = new Set([408, 429, 500, 502, 503, 504]);
4
+ const TRANSIENT_CODES = new Set([
5
+ 'TIMEOUT',
6
+ 'ETIMEDOUT',
7
+ 'ECONNABORTED',
8
+ 'ECONNRESET',
9
+ 'EPIPE',
10
+ 'ECONNREFUSED',
11
+ 'EAI_AGAIN',
12
+ 'ENETDOWN',
13
+ 'ENETUNREACH',
14
+ 'EHOSTDOWN',
15
+ 'EHOSTUNREACH',
16
+ 'UND_ERR_CONNECT_TIMEOUT',
17
+ 'UND_ERR_HEADERS_TIMEOUT',
18
+ 'UND_ERR_BODY_TIMEOUT',
19
+ 'UND_ERR_SOCKET',
20
+ ]);
21
+ const record = (value: unknown): Record<string, unknown> | undefined =>
22
+ typeof value === 'object' && value !== null
23
+ ? (value as Record<string, unknown>)
24
+ : undefined;
25
+ const temporaryRpc = (value: unknown): boolean => {
26
+ const error = record(value);
27
+ return (
28
+ typeof error?.message === 'string' &&
29
+ (error.code === -32002 ||
30
+ error.code === -32017 ||
31
+ (error.code === -32000 &&
32
+ /no backends available|temporarily unavailable/i.test(error.message)))
33
+ );
34
+ };
35
+
36
+ /** Read-only receipt failures. Never use this to decide to resend a transaction. */
37
+ export function isTransientReceiptError(error: unknown): boolean {
38
+ let transient = false;
39
+ for (const entry of errorChain(error)) {
40
+ const failure = record(entry);
41
+ if (!failure) continue;
42
+ const response = record(failure.response);
43
+ const status = response?.statusCode;
44
+ if (typeof status === 'number' && (status < 200 || status >= 300)) {
45
+ // Ethers escalates exhausted HTTP throttling to a synthetic 599.
46
+ // Other 599 errors (post-processing, invalid gateways) remain failures.
47
+ const exhaustedThrottle =
48
+ failure.code === 'SERVER_ERROR' &&
49
+ status === 599 &&
50
+ failure.shortMessage === 'exceeded maximum retry limit' &&
51
+ typeof response?.statusMessage === 'string' &&
52
+ response.statusMessage.startsWith(
53
+ 'CLIENT ESCALATED SERVER ERROR (429 ',
54
+ );
55
+ if (!TRANSIENT_HTTP.has(status) && !exhaustedThrottle) return false;
56
+ transient = true;
57
+ continue;
58
+ }
59
+ if (failure.code === 'NETWORK_ERROR') return false;
60
+ if (failure.code === 'UNKNOWN_ERROR' && failure.error !== undefined) {
61
+ if (!temporaryRpc(failure.error)) return false;
62
+ transient = true;
63
+ } else if (failure.code === 'BAD_DATA') {
64
+ // Missing request IDs are only transient when every returned error is
65
+ // a recognised provider timeout/rate-limit response. BAD_DATA alone isn't.
66
+ if (
67
+ failure.shortMessage !== 'missing response for request' ||
68
+ !Array.isArray(failure.value) ||
69
+ failure.value.length === 0 ||
70
+ !failure.value.every((value: unknown) => {
71
+ const reply = record(value);
72
+ return reply?.id === null && temporaryRpc(reply.error);
73
+ })
74
+ )
75
+ return false;
76
+ transient = true;
77
+ } else if (
78
+ typeof failure.code === 'string' &&
79
+ TRANSIENT_CODES.has(failure.code)
80
+ ) {
81
+ transient = true;
82
+ }
83
+ }
84
+ return transient;
85
+ }
@@ -1,9 +1,13 @@
1
- import { Provider } from 'ethers';
1
+ import { makeError, Provider } from 'ethers';
2
2
  import { delay } from './delay';
3
+ import { isTransientReceiptError } from './receipt-rpc-error';
3
4
 
4
5
  type TransactionReceiptMetadata = {
5
6
  pollingInterval?: number;
7
+ /** Maximum read attempts, including the initial read. */
6
8
  maxRetries?: number;
9
+ /** Total observation budget, including RPC latency and provider retries. */
10
+ timeoutMs?: number;
7
11
  };
8
12
 
9
13
  export const getTransactionReceipt = async (
@@ -11,19 +15,56 @@ export const getTransactionReceipt = async (
11
15
  txHash: string,
12
16
  metadata: TransactionReceiptMetadata = {},
13
17
  ) => {
14
- const maxRetries = metadata.maxRetries || 40;
15
- const pollingInterval = metadata.pollingInterval || 50;
16
-
18
+ const maxRetries = metadata.maxRetries ?? 40;
19
+ const pollingInterval = metadata.pollingInterval ?? 50;
20
+ // Preserve the intended 40 x 50ms observation budget, now including RPC time.
21
+ const timeoutMs = metadata.timeoutMs ?? maxRetries * pollingInterval;
22
+ for (const [name, value] of Object.entries({
23
+ maxRetries,
24
+ pollingInterval,
25
+ timeoutMs,
26
+ })) {
27
+ if (!Number.isSafeInteger(value) || value < 1 || value > 2 ** 31 - 1) {
28
+ throw new Error(
29
+ `${name} must be a positive integer no greater than ${2 ** 31 - 1}`,
30
+ );
31
+ }
32
+ }
33
+ const deadline = performance.now() + timeoutMs;
34
+ let lastError: unknown;
17
35
  for (let attempts = 0; attempts < maxRetries; attempts++) {
18
- const receipt = await provider.getTransactionReceipt(txHash);
19
- if (receipt) {
20
- return receipt;
36
+ const remaining = deadline - performance.now();
37
+ if (remaining <= 0) break;
38
+ let timer: ReturnType<typeof setTimeout> | undefined;
39
+ let expired = false;
40
+ try {
41
+ // Provider has no per-call abort API. Stop waiting at the deadline and
42
+ // never start another poll alongside an unfinished provider request.
43
+ const receipt = await Promise.race([
44
+ provider.getTransactionReceipt(txHash),
45
+ new Promise<never>((_, reject) => {
46
+ timer = setTimeout(() => {
47
+ expired = true;
48
+ reject(
49
+ makeError('Receipt observation timed out', 'TIMEOUT', {
50
+ operation: 'getTransactionReceipt',
51
+ reason: 'timeout',
52
+ }),
53
+ );
54
+ }, remaining);
55
+ }),
56
+ ]);
57
+ if (receipt) return receipt;
58
+ lastError = undefined;
59
+ } catch (error) {
60
+ if (expired || !isTransientReceiptError(error)) throw error;
61
+ lastError = error;
62
+ } finally {
63
+ if (timer !== undefined) clearTimeout(timer);
21
64
  }
22
-
23
- // Wait for the specified interval before retrying
24
- await delay(pollingInterval);
25
- attempts++;
65
+ const wait = Math.min(pollingInterval, deadline - performance.now());
66
+ if (attempts + 1 < maxRetries && wait > 0) await delay(wait);
26
67
  }
27
-
68
+ if (lastError !== undefined) throw lastError;
28
69
  return null;
29
70
  };