0g-orbit 0.2.2 → 0.2.3

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 (59) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +21 -0
  3. package/README.md +151 -0
  4. package/dist/cli/cli.js +1 -1
  5. package/dist/storage.d.ts.map +1 -1
  6. package/dist/storage.js +23 -1
  7. package/dist/storage.js.map +1 -1
  8. package/package.json +28 -4
  9. package/examples/ai-chatbot/index.ts +0 -74
  10. package/examples/model-registry/index.ts +0 -137
  11. package/examples/quick-start/index.ts +0 -65
  12. package/packages/cli/package.json +0 -30
  13. package/packages/cli/src/cli.ts +0 -69
  14. package/packages/cli/src/commands/account.ts +0 -29
  15. package/packages/cli/src/commands/inference.ts +0 -103
  16. package/packages/cli/src/commands/init.ts +0 -71
  17. package/packages/cli/src/commands/storage.ts +0 -91
  18. package/packages/cli/src/utils.ts +0 -21
  19. package/packages/cli/tsconfig.json +0 -8
  20. package/packages/core/package.json +0 -35
  21. package/packages/core/src/errors.test.ts +0 -99
  22. package/packages/core/src/errors.ts +0 -79
  23. package/packages/core/src/index.ts +0 -37
  24. package/packages/core/src/inference.ts +0 -256
  25. package/packages/core/src/networks.test.ts +0 -62
  26. package/packages/core/src/networks.ts +0 -62
  27. package/packages/core/src/orbit.test.ts +0 -153
  28. package/packages/core/src/orbit.ts +0 -159
  29. package/packages/core/src/retry.test.ts +0 -99
  30. package/packages/core/src/retry.ts +0 -99
  31. package/packages/core/src/storage.test.ts +0 -199
  32. package/packages/core/src/storage.ts +0 -158
  33. package/packages/core/src/types.ts +0 -85
  34. package/packages/core/tsconfig.json +0 -8
  35. package/packages/core/vitest.config.ts +0 -7
  36. package/src/cli/cli.ts +0 -95
  37. package/src/cli/commands/account.ts +0 -29
  38. package/src/cli/commands/fine-tuning.ts +0 -169
  39. package/src/cli/commands/inference.ts +0 -103
  40. package/src/cli/commands/init.ts +0 -71
  41. package/src/cli/commands/storage.ts +0 -91
  42. package/src/cli/utils.ts +0 -21
  43. package/src/errors.test.ts +0 -99
  44. package/src/errors.ts +0 -90
  45. package/src/fine-tuning.test.ts +0 -299
  46. package/src/fine-tuning.ts +0 -330
  47. package/src/index.ts +0 -45
  48. package/src/inference.ts +0 -256
  49. package/src/networks.test.ts +0 -62
  50. package/src/networks.ts +0 -62
  51. package/src/orbit.test.ts +0 -153
  52. package/src/orbit.ts +0 -204
  53. package/src/retry.test.ts +0 -99
  54. package/src/retry.ts +0 -99
  55. package/src/storage.test.ts +0 -199
  56. package/src/storage.ts +0 -158
  57. package/src/types.ts +0 -157
  58. package/tsconfig.json +0 -20
  59. package/vitest.config.ts +0 -7
package/src/networks.ts DELETED
@@ -1,62 +0,0 @@
1
- export type NetworkName = 'testnet' | 'mainnet'
2
-
3
- export interface NetworkConfig {
4
- name: NetworkName
5
- chainId: bigint
6
- rpcUrl: string
7
- /** Fallback RPC URLs, tried in order if the primary fails */
8
- rpcUrls: string[]
9
- indexerUrl: string
10
- flowContractAddress: string
11
- ledgerContractAddress: string
12
- inferenceContractAddress: string
13
- fineTuningContractAddress: string
14
- }
15
-
16
- export const NETWORKS: Record<NetworkName, NetworkConfig> = {
17
- testnet: {
18
- name: 'testnet',
19
- chainId: 16602n,
20
- rpcUrl: 'https://evmrpc-testnet.0g.ai',
21
- rpcUrls: [
22
- 'https://evmrpc-testnet.0g.ai',
23
- 'https://16600-rpc.testnet.0g.ai',
24
- 'https://og-testnet-evm-rpc.allthatnode.com',
25
- ],
26
- indexerUrl: 'https://indexer-storage-testnet-turbo.0g.ai',
27
- flowContractAddress: '0xbD2C3F0E65eDF5582141C35969d66e34e4E6BF80',
28
- ledgerContractAddress: '0xE70830508dAc0A97e6c087c75f402f9Be669E406',
29
- inferenceContractAddress: '0xa79F4c8311FF93C06b8CfB403690cc987c93F91E',
30
- fineTuningContractAddress: '0xC6C075D8039763C8f1EbE580be5ADdf2fd6941bA',
31
- },
32
- mainnet: {
33
- name: 'mainnet',
34
- chainId: 16661n,
35
- rpcUrl: 'https://evmrpc.0g.ai',
36
- rpcUrls: [
37
- 'https://evmrpc.0g.ai',
38
- 'https://0g-rpc-evm01.validatorvn.com',
39
- 'https://rpc.0g.thirdweb.com',
40
- ],
41
- indexerUrl: 'https://indexer-storage.0g.ai',
42
- flowContractAddress: '0x0460aA47b41a66694c0a73f667a1b795A5ED3556',
43
- ledgerContractAddress: '0x2dE54c845Cd948B72D2e32e39586fe89607074E3',
44
- inferenceContractAddress: '0x47340d900bdFec2BD393c626E12ea0656F938d84',
45
- fineTuningContractAddress: '0x4e3474095518883744ddf135b7E0A23301c7F9c0',
46
- },
47
- }
48
-
49
- export function getNetwork(nameOrChainId: NetworkName | bigint): NetworkConfig {
50
- if (typeof nameOrChainId === 'string') {
51
- const config = NETWORKS[nameOrChainId]
52
- if (!config) throw new Error(`Unknown network: ${nameOrChainId}`)
53
- // Return a copy so overrides don't mutate the original
54
- return { ...config, rpcUrls: [...config.rpcUrls] }
55
- }
56
- for (const config of Object.values(NETWORKS)) {
57
- if (config.chainId === nameOrChainId) {
58
- return { ...config, rpcUrls: [...config.rpcUrls] }
59
- }
60
- }
61
- throw new Error(`Unknown chain ID: ${nameOrChainId}`)
62
- }
package/src/orbit.test.ts DELETED
@@ -1,153 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest'
2
- import { ConnectionError } from './errors.js'
3
-
4
- // Mock ethers before importing Orbit
5
- vi.mock('ethers', () => {
6
- const mockProvider = {
7
- getNetwork: vi.fn().mockResolvedValue({ chainId: 16602n }),
8
- getBalance: vi.fn().mockResolvedValue(1000000000000000000n), // 1 OG
9
- }
10
- const MockJsonRpcProvider = vi.fn(() => mockProvider)
11
- const MockWallet = vi.fn((key: string, provider: any) => ({
12
- address: '0x1234567890abcdef1234567890abcdef12345678',
13
- provider,
14
- }))
15
- return {
16
- JsonRpcProvider: MockJsonRpcProvider,
17
- Wallet: MockWallet,
18
- }
19
- })
20
-
21
- // Mock the storage and inference clients
22
- vi.mock('./storage.js', () => ({
23
- StorageClient: vi.fn(),
24
- }))
25
- vi.mock('./inference.js', () => ({
26
- InferenceClient: vi.fn(),
27
- }))
28
-
29
- import { Orbit } from './orbit.js'
30
- import { JsonRpcProvider } from 'ethers'
31
-
32
- describe('Orbit.connect', () => {
33
- beforeEach(() => {
34
- vi.clearAllMocks()
35
- delete process.env.PRIVATE_KEY
36
- })
37
-
38
- it('connects with explicit private key', async () => {
39
- const orbit = await Orbit.connect({
40
- network: 'testnet',
41
- privateKey: '0xabc123',
42
- })
43
- expect(orbit.address).toBe('0x1234567890abcdef1234567890abcdef12345678')
44
- expect(orbit.network.name).toBe('testnet')
45
- })
46
-
47
- it('reads PRIVATE_KEY from env var', async () => {
48
- process.env.PRIVATE_KEY = '0xenv_key_123'
49
- const orbit = await Orbit.connect({ network: 'testnet' })
50
- expect(orbit.address).toBeDefined()
51
- })
52
-
53
- it('throws with suggestion when no private key available', async () => {
54
- await expect(
55
- Orbit.connect({ network: 'testnet' })
56
- ).rejects.toThrow('No private key provided')
57
-
58
- try {
59
- await Orbit.connect({ network: 'testnet' })
60
- } catch (err) {
61
- expect(err).toBeInstanceOf(ConnectionError)
62
- expect((err as ConnectionError).suggestion).toContain('PRIVATE_KEY')
63
- }
64
- })
65
-
66
- it('throws on chain ID mismatch', async () => {
67
- const { JsonRpcProvider } = await import('ethers')
68
- const mockProvider = new JsonRpcProvider() as any
69
- mockProvider.getNetwork.mockResolvedValueOnce({ chainId: 99999n })
70
-
71
- await expect(
72
- Orbit.connect({ network: 'testnet', privateKey: '0xabc' })
73
- ).rejects.toThrow('Chain ID mismatch')
74
- })
75
-
76
- it('warns on zero balance but still connects', async () => {
77
- const { JsonRpcProvider } = await import('ethers')
78
- const mockProvider = new JsonRpcProvider() as any
79
- mockProvider.getNetwork.mockResolvedValueOnce({ chainId: 16602n })
80
- mockProvider.getBalance.mockResolvedValueOnce(0n)
81
-
82
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
83
-
84
- const orbit = await Orbit.connect({
85
- network: 'testnet',
86
- privateKey: '0xabc',
87
- })
88
-
89
- expect(orbit).toBeDefined()
90
- expect(warnSpy).toHaveBeenCalledWith(
91
- expect.stringContaining('0 OG balance')
92
- )
93
- warnSpy.mockRestore()
94
- })
95
-
96
- it('tries fallback RPC URLs on connection failure', async () => {
97
- const { JsonRpcProvider } = await import('ethers')
98
-
99
- // First two URLs fail, third succeeds
100
- let callCount = 0
101
- ;(JsonRpcProvider as any).mockImplementation(() => {
102
- callCount++
103
- const provider = {
104
- getNetwork: callCount < 3
105
- ? vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))
106
- : vi.fn().mockResolvedValue({ chainId: 16602n }),
107
- getBalance: vi.fn().mockResolvedValue(1000000000000000000n),
108
- }
109
- return provider
110
- })
111
-
112
- const orbit = await Orbit.connect({
113
- network: 'testnet',
114
- privateKey: '0xabc',
115
- })
116
- expect(orbit).toBeDefined()
117
- expect(callCount).toBe(3)
118
- })
119
-
120
- it('throws when all RPC URLs fail', async () => {
121
- const { JsonRpcProvider } = await import('ethers')
122
- ;(JsonRpcProvider as any).mockImplementation(() => ({
123
- getNetwork: vi.fn().mockRejectedValue(new Error('ECONNREFUSED')),
124
- }))
125
-
126
- await expect(
127
- Orbit.connect({ network: 'testnet', privateKey: '0xabc' })
128
- ).rejects.toThrow('Failed to connect to any RPC endpoint')
129
- })
130
- })
131
-
132
- describe('Orbit.status', () => {
133
- beforeEach(() => {
134
- vi.clearAllMocks()
135
- // Restore default mock after tests that override JsonRpcProvider
136
- const mockProvider = {
137
- getNetwork: vi.fn().mockResolvedValue({ chainId: 16602n }),
138
- getBalance: vi.fn().mockResolvedValue(1000000000000000000n),
139
- }
140
- ;(JsonRpcProvider as any).mockImplementation(() => mockProvider)
141
- })
142
-
143
- it('returns formatted balance and address', async () => {
144
- const orbit = await Orbit.connect({
145
- network: 'testnet',
146
- privateKey: '0xabc',
147
- })
148
- const status = await orbit.status()
149
- expect(status.network).toBe('testnet')
150
- expect(status.address).toBe('0x1234567890abcdef1234567890abcdef12345678')
151
- expect(status.balance).toMatch(/^\d+\.\d{6}$/)
152
- })
153
- })
package/src/orbit.ts DELETED
@@ -1,204 +0,0 @@
1
- import { Wallet, JsonRpcProvider } from 'ethers'
2
- import type { NetworkName, NetworkConfig } from './networks.js'
3
- import { getNetwork, NETWORKS } from './networks.js'
4
- import { StorageClient } from './storage.js'
5
- import { InferenceClient } from './inference.js'
6
- import { FineTuningClient } from './fine-tuning.js'
7
- import type {
8
- OrbitConfig,
9
- StoreResult,
10
- StoreOptions,
11
- RetrieveOptions,
12
- InferResult,
13
- InferOptions,
14
- ServiceInfo,
15
- AccountStatus,
16
- DatasetUploadResult,
17
- CreateTaskOptions,
18
- FineTuneTask,
19
- FineTuneModel,
20
- FineTuneProvider,
21
- } from './types.js'
22
- import { ConnectionError } from './errors.js'
23
-
24
- export class Orbit {
25
- readonly network: NetworkConfig
26
- readonly storage: StorageClient
27
- readonly inference: InferenceClient
28
- private _fineTuning: FineTuningClient | null = null
29
- private wallet: Wallet
30
- private provider: JsonRpcProvider
31
-
32
- private constructor(
33
- network: NetworkConfig,
34
- wallet: Wallet,
35
- provider: JsonRpcProvider
36
- ) {
37
- this.network = network
38
- this.wallet = wallet
39
- this.provider = provider
40
- this.storage = new StorageClient(network, wallet)
41
- this.inference = new InferenceClient(network, wallet)
42
- }
43
-
44
- /** Lazy-initialized fine-tuning client */
45
- get fineTuning(): FineTuningClient {
46
- if (!this._fineTuning) {
47
- this._fineTuning = new FineTuningClient(this.network, this.wallet, this.storage)
48
- }
49
- return this._fineTuning
50
- }
51
-
52
- /**
53
- * Connect to the 0G network. This is the primary entry point.
54
- *
55
- * Tries the primary RPC URL first, then falls back to alternates.
56
- * If privateKey is not provided, reads from PRIVATE_KEY env var.
57
- */
58
- static async connect(config: OrbitConfig): Promise<Orbit> {
59
- // Resolve private key: explicit > env var
60
- const privateKey = config.privateKey ?? process.env.PRIVATE_KEY
61
- if (!privateKey) {
62
- throw new ConnectionError(
63
- 'No private key provided.',
64
- 'Pass privateKey in config or set the PRIVATE_KEY environment variable. Example: PRIVATE_KEY=0x... npx tsx index.ts'
65
- )
66
- }
67
-
68
- const networkConfig = getNetwork(config.network)
69
-
70
- // Allow overrides
71
- if (config.rpcUrl) networkConfig.rpcUrl = config.rpcUrl
72
- if (config.indexerUrl) networkConfig.indexerUrl = config.indexerUrl
73
-
74
- // Build candidate RPC URLs: explicit override first, then the fallback list
75
- const rpcUrls = config.rpcUrl
76
- ? [config.rpcUrl]
77
- : networkConfig.rpcUrls
78
-
79
- // Try each RPC URL until one connects
80
- let lastError: Error | null = null
81
- for (const url of rpcUrls) {
82
- try {
83
- const provider = new JsonRpcProvider(url)
84
- const wallet = new Wallet(privateKey, provider)
85
-
86
- const chainId = (await provider.getNetwork()).chainId
87
- if (chainId !== networkConfig.chainId) {
88
- throw new ConnectionError(
89
- `Chain ID mismatch: expected ${networkConfig.chainId}, got ${chainId}`,
90
- `Your RPC endpoint is on a different network. Use the correct RPC URL for ${networkConfig.name}.`
91
- )
92
- }
93
-
94
- // Lock in the working URL
95
- networkConfig.rpcUrl = url
96
-
97
- // Check balance and warn if zero (non-blocking)
98
- try {
99
- const balance = await provider.getBalance(wallet.address)
100
- if (balance === 0n) {
101
- console.warn(
102
- `[0G Orbit] Warning: wallet ${wallet.address} has 0 OG balance on ${networkConfig.name}. ` +
103
- `Get testnet tokens at https://faucet.0g.ai`
104
- )
105
- }
106
- } catch {
107
- // Balance check failure is non-fatal
108
- }
109
-
110
- return new Orbit(networkConfig, wallet, provider)
111
- } catch (err) {
112
- // Chain ID mismatch is not a transient failure — don't try other URLs
113
- if (err instanceof ConnectionError && err.message.includes('Chain ID mismatch')) {
114
- throw err
115
- }
116
- lastError = err instanceof Error ? err : new Error(String(err))
117
- }
118
- }
119
-
120
- const tried = rpcUrls.join(', ')
121
- throw new ConnectionError(
122
- `Failed to connect to any RPC endpoint. Tried: ${tried}. Last error: ${lastError?.message ?? 'unknown'}`,
123
- 'All RPC endpoints are unreachable. Check your internet connection, or provide a custom RPC URL with the rpcUrl option.'
124
- )
125
- }
126
-
127
- // --- Storage shortcuts ---
128
-
129
- async store(filePath: string, options?: StoreOptions): Promise<StoreResult> {
130
- return this.storage.store(filePath, options)
131
- }
132
-
133
- async storeData(
134
- data: string | Buffer | Uint8Array,
135
- options?: StoreOptions
136
- ): Promise<StoreResult> {
137
- return this.storage.storeData(data, options)
138
- }
139
-
140
- async retrieve(
141
- rootHash: string,
142
- outputPath: string,
143
- options?: RetrieveOptions
144
- ): Promise<void> {
145
- return this.storage.retrieve(rootHash, outputPath, options)
146
- }
147
-
148
- // --- Inference shortcuts ---
149
-
150
- async infer(model: string, options: InferOptions): Promise<InferResult> {
151
- return this.inference.infer(model, options)
152
- }
153
-
154
- async listServices(): Promise<ServiceInfo[]> {
155
- return this.inference.listServices()
156
- }
157
-
158
- // --- Fine-Tuning shortcuts ---
159
-
160
- async uploadDataset(filePath: string): Promise<DatasetUploadResult> {
161
- return this.fineTuning.uploadDataset(filePath)
162
- }
163
-
164
- async createFineTuneTask(options: CreateTaskOptions): Promise<FineTuneTask> {
165
- return this.fineTuning.createTask(options)
166
- }
167
-
168
- async getFineTuneTask(providerAddress: string, taskId: string): Promise<FineTuneTask> {
169
- return this.fineTuning.getTask(providerAddress, taskId)
170
- }
171
-
172
- async downloadModel(
173
- providerAddress: string,
174
- taskId: string,
175
- outputPath: string
176
- ): Promise<void> {
177
- return this.fineTuning.downloadModel(providerAddress, taskId, outputPath)
178
- }
179
-
180
- async listModels(): Promise<FineTuneModel[]> {
181
- return this.fineTuning.listModels()
182
- }
183
-
184
- async listProviders(): Promise<FineTuneProvider[]> {
185
- return this.fineTuning.listProviders()
186
- }
187
-
188
- // --- Account ---
189
-
190
- async status(): Promise<AccountStatus> {
191
- const balance = await this.provider.getBalance(this.wallet.address)
192
- const balanceOG = Number(balance) / 1e18
193
-
194
- return {
195
- balance: balanceOG.toFixed(6),
196
- network: this.network.name,
197
- address: this.wallet.address,
198
- }
199
- }
200
-
201
- get address(): string {
202
- return this.wallet.address
203
- }
204
- }
package/src/retry.test.ts DELETED
@@ -1,99 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest'
2
- import { withRetry, isTransientError } from './retry.js'
3
-
4
- describe('withRetry', () => {
5
- it('returns on first success', async () => {
6
- const fn = vi.fn().mockResolvedValue('ok')
7
- const result = await withRetry(fn)
8
- expect(result).toBe('ok')
9
- expect(fn).toHaveBeenCalledTimes(1)
10
- })
11
-
12
- it('retries on transient error and succeeds', async () => {
13
- const fn = vi.fn()
14
- .mockRejectedValueOnce(new Error('ECONNREFUSED'))
15
- .mockResolvedValue('ok')
16
-
17
- const result = await withRetry(fn, { baseDelay: 10 })
18
- expect(result).toBe('ok')
19
- expect(fn).toHaveBeenCalledTimes(2)
20
- })
21
-
22
- it('throws after maxAttempts exhausted', async () => {
23
- const fn = vi.fn().mockRejectedValue(new Error('ETIMEDOUT'))
24
-
25
- await expect(
26
- withRetry(fn, { maxAttempts: 3, baseDelay: 10 })
27
- ).rejects.toThrow('ETIMEDOUT')
28
- expect(fn).toHaveBeenCalledTimes(3)
29
- })
30
-
31
- it('does not retry non-transient errors', async () => {
32
- const fn = vi.fn().mockRejectedValue(new Error('Invalid private key'))
33
-
34
- await expect(
35
- withRetry(fn, { maxAttempts: 3, baseDelay: 10 })
36
- ).rejects.toThrow('Invalid private key')
37
- expect(fn).toHaveBeenCalledTimes(1)
38
- })
39
-
40
- it('calls onRetry callback', async () => {
41
- const onRetry = vi.fn()
42
- const fn = vi.fn()
43
- .mockRejectedValueOnce(new Error('ECONNRESET'))
44
- .mockResolvedValue('ok')
45
-
46
- await withRetry(fn, { baseDelay: 10, onRetry })
47
- expect(onRetry).toHaveBeenCalledTimes(1)
48
- expect(onRetry).toHaveBeenCalledWith(1, expect.any(Error))
49
- })
50
-
51
- it('respects custom isRetryable predicate', async () => {
52
- const fn = vi.fn()
53
- .mockRejectedValueOnce(new Error('custom-retryable'))
54
- .mockResolvedValue('ok')
55
-
56
- const result = await withRetry(fn, {
57
- baseDelay: 10,
58
- isRetryable: (err) => err instanceof Error && err.message === 'custom-retryable',
59
- })
60
- expect(result).toBe('ok')
61
- expect(fn).toHaveBeenCalledTimes(2)
62
- })
63
- })
64
-
65
- describe('isTransientError', () => {
66
- it.each([
67
- 'ECONNREFUSED',
68
- 'ECONNRESET',
69
- 'ETIMEDOUT',
70
- 'ENOTFOUND',
71
- 'fetch failed',
72
- 'network error',
73
- 'timeout exceeded',
74
- 'server error',
75
- 'bad gateway',
76
- 'service unavailable',
77
- 'rate limit exceeded',
78
- 'missing response',
79
- 'connection error',
80
- 'could not detect network',
81
- ])('returns true for "%s"', (msg) => {
82
- expect(isTransientError(new Error(msg))).toBe(true)
83
- })
84
-
85
- it.each([
86
- 'Invalid private key',
87
- 'insufficient funds for gas',
88
- 'File not found',
89
- 'unknown error',
90
- ])('returns false for "%s"', (msg) => {
91
- expect(isTransientError(new Error(msg))).toBe(false)
92
- })
93
-
94
- it('returns false for non-Error values', () => {
95
- expect(isTransientError('string error')).toBe(false)
96
- expect(isTransientError(null)).toBe(false)
97
- expect(isTransientError(42)).toBe(false)
98
- })
99
- })
package/src/retry.ts DELETED
@@ -1,99 +0,0 @@
1
- export interface RetryOptions {
2
- /** Maximum number of attempts (default: 3) */
3
- maxAttempts?: number
4
- /** Base delay between retries in ms (default: 1000). Doubles each retry. */
5
- baseDelay?: number
6
- /** Maximum delay cap in ms (default: 10000) */
7
- maxDelay?: number
8
- /** Predicate to decide if an error is retryable (default: transient errors only) */
9
- isRetryable?: (err: unknown) => boolean
10
- /** Called before each retry with attempt number and error */
11
- onRetry?: (attempt: number, err: unknown) => void
12
- }
13
-
14
- const DEFAULT_OPTIONS: Required<Omit<RetryOptions, 'onRetry'>> = {
15
- maxAttempts: 3,
16
- baseDelay: 1000,
17
- maxDelay: 10_000,
18
- isRetryable: isTransientError,
19
- }
20
-
21
- /**
22
- * Retry an async operation with exponential backoff.
23
- * Only retries on transient errors (network, timeout, 5xx) by default.
24
- */
25
- export async function withRetry<T>(
26
- fn: () => Promise<T>,
27
- options: RetryOptions = {}
28
- ): Promise<T> {
29
- const opts = { ...DEFAULT_OPTIONS, ...options }
30
- let lastError: unknown
31
-
32
- for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
33
- try {
34
- return await fn()
35
- } catch (err) {
36
- lastError = err
37
-
38
- if (attempt === opts.maxAttempts || !opts.isRetryable(err)) {
39
- throw err
40
- }
41
-
42
- if (options.onRetry) {
43
- options.onRetry(attempt, err)
44
- }
45
-
46
- // Exponential backoff with jitter
47
- const delay = Math.min(
48
- opts.baseDelay * Math.pow(2, attempt - 1) + Math.random() * 500,
49
- opts.maxDelay
50
- )
51
- await sleep(delay)
52
- }
53
- }
54
-
55
- throw lastError
56
- }
57
-
58
- function sleep(ms: number): Promise<void> {
59
- return new Promise((resolve) => setTimeout(resolve, ms))
60
- }
61
-
62
- /**
63
- * Determines if an error is transient and worth retrying.
64
- * Covers: network failures, timeouts, RPC errors, 5xx responses.
65
- */
66
- export function isTransientError(err: unknown): boolean {
67
- if (!(err instanceof Error)) return false
68
- const msg = err.message.toLowerCase()
69
-
70
- // Network-level failures
71
- if (msg.includes('econnrefused')) return true
72
- if (msg.includes('econnreset')) return true
73
- if (msg.includes('etimedout')) return true
74
- if (msg.includes('enotfound')) return true
75
- if (msg.includes('epipe')) return true
76
- if (msg.includes('fetch failed')) return true
77
- if (msg.includes('network error')) return true
78
-
79
- // Timeout
80
- if (msg.includes('timeout')) return true
81
- if (err.name === 'AbortError') return true
82
-
83
- // RPC-level transient errors
84
- if (msg.includes('server error')) return true
85
- if (msg.includes('bad gateway')) return true
86
- if (msg.includes('service unavailable')) return true
87
- if (msg.includes('rate limit')) return true
88
- if (msg.includes('429')) return true
89
- if (msg.includes('502')) return true
90
- if (msg.includes('503')) return true
91
- if (msg.includes('504')) return true
92
-
93
- // ethers provider errors
94
- if (msg.includes('missing response')) return true
95
- if (msg.includes('connection error')) return true
96
- if (msg.includes('could not detect network')) return true
97
-
98
- return false
99
- }