tapimo 0.0.1 → 0.0.2

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.
@@ -1048,7 +1048,7 @@ async function loadDexBalances(
1048
1048
  account: dexAccount,
1049
1049
  token: token as ViemAddress,
1050
1050
  })
1051
- balances.set(token, balance)
1051
+ balances.set(token, balance.amount)
1052
1052
  } catch {
1053
1053
  failed = true
1054
1054
  balances.set(token, 0n)
@@ -117,7 +117,7 @@ describe('GET /indexer/query', () => {
117
117
  getClient: () => Tempo.client,
118
118
  recipient: Tempo.accounts[2].address,
119
119
  },
120
- secretKey: 'secret_test_key',
120
+ secretKey: 'secret_test_key_0123456789abcdef',
121
121
  },
122
122
  })
123
123
  await exhaustQuota(RateLimit.memory({ store: rateLimitStore }), 'public:anonymous', {
@@ -1119,7 +1119,7 @@ describe('MPP access (disabled for now — API-key-only)', () => {
1119
1119
  getClient: () => Tempo.client,
1120
1120
  recipient: Tempo.accounts[2].address,
1121
1121
  },
1122
- secretKey: 'secret_test_key',
1122
+ secretKey: 'secret_test_key_0123456789abcdef',
1123
1123
  },
1124
1124
  webhook: { store: Store.memory() },
1125
1125
  })
@@ -555,6 +555,64 @@ describe('require', () => {
555
555
  }
556
556
  `)
557
557
  })
558
+
559
+ test('fails open when the public rate-limit store errors', async () => {
560
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {})
561
+ try {
562
+ const app = createApp({
563
+ auth: {
564
+ apiKey: { source: TestApp.source({ keys: [{ ...key, token }] }) },
565
+ rateLimit: {
566
+ async consume() {
567
+ throw new Error('Network connection lost.')
568
+ },
569
+ },
570
+ },
571
+ policy: { public: { rateLimit: { perMinute: 1 } } },
572
+ })
573
+ const response = await app.request('/protected', {
574
+ headers: { 'x-forwarded-for': '203.0.113.10' },
575
+ })
576
+ const body = await response.json()
577
+
578
+ expect(response.status).toMatchInlineSnapshot(`200`)
579
+ expect(response.headers.has('ratelimit-limit')).toMatchInlineSnapshot(`false`)
580
+ expect(body).toMatchInlineSnapshot(`
581
+ {
582
+ "id": "203.0.113.10",
583
+ "type": "public",
584
+ }
585
+ `)
586
+ expect(error.mock.calls.length).toMatchInlineSnapshot(`1`)
587
+ } finally {
588
+ error.mockRestore()
589
+ }
590
+ })
591
+
592
+ test('fails open when the API-key rate-limit store errors', async () => {
593
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {})
594
+ try {
595
+ const app = createApp({
596
+ auth: {
597
+ apiKey: { source: TestApp.source({ keys: [{ ...key, token }] }) },
598
+ rateLimit: {
599
+ async consume() {
600
+ throw new Error('Network connection lost.')
601
+ },
602
+ },
603
+ },
604
+ })
605
+ const response = await app.request('/protected', {
606
+ headers: { 'tempo-api-key': token },
607
+ })
608
+
609
+ expect(response.status).toMatchInlineSnapshot(`200`)
610
+ expect(response.headers.has('ratelimit-limit')).toMatchInlineSnapshot(`false`)
611
+ expect(error.mock.calls.length).toMatchInlineSnapshot(`1`)
612
+ } finally {
613
+ error.mockRestore()
614
+ }
615
+ })
558
616
  })
559
617
 
560
618
  describe('rateLimit per-scope', () => {
@@ -845,7 +903,7 @@ function createMppServer() {
845
903
  }),
846
904
  ],
847
905
  realm: 'tempo-api-test',
848
- secretKey: 'secret_test_key',
906
+ secretKey: 'secret_test_key_0123456789abcdef',
849
907
  })
850
908
  }
851
909
 
@@ -796,7 +796,12 @@ function consumeRateLimit(
796
796
  options: RateLimit.Store.ConsumeOptions,
797
797
  ): Promise<RateLimit.Result | null> {
798
798
  if (!auth.rateLimit) return Promise.resolve(null)
799
- return auth.rateLimit.consume(options)
799
+ // Fail open: a rate-limit store error (e.g. a lost Durable Object
800
+ // connection) degrades to unenforced quota instead of failing the request.
801
+ return auth.rateLimit.consume(options).catch((error) => {
802
+ console.error('[auth] rate-limit consume failed; failing open', error)
803
+ return null
804
+ })
800
805
  }
801
806
 
802
807
  /**
@@ -286,6 +286,90 @@ describe('durableObject', () => {
286
286
  )
287
287
  })
288
288
 
289
+ test('retries a retryable stub error once on a fresh stub', async () => {
290
+ let stubs = 0
291
+ let failures = 1
292
+ const backing = Store.memory()
293
+ const namespace = {
294
+ getByName() {
295
+ stubs += 1
296
+ return {
297
+ ...backing,
298
+ increment: (key, options) => {
299
+ if (failures > 0) {
300
+ failures -= 1
301
+ throw Object.assign(new Error('Network connection lost.'), { retryable: true })
302
+ }
303
+ return Store.increment(backing, key, options)
304
+ },
305
+ }
306
+ },
307
+ } satisfies Store.durableObject.Namespace
308
+ const store = Store.durableObject(namespace, { name: 'tempo-api' })
309
+
310
+ expect(await Store.increment(store, 'counter')).toMatchInlineSnapshot(`1`)
311
+ // One stub per attempt: a stub that threw is disconnected.
312
+ expect(stubs).toMatchInlineSnapshot(`2`)
313
+ })
314
+
315
+ test('propagates a retryable stub error when the retry also fails', async () => {
316
+ let attempts = 0
317
+ const namespace = {
318
+ getByName() {
319
+ return {
320
+ ...Store.memory(),
321
+ increment: () => {
322
+ attempts += 1
323
+ throw Object.assign(new Error('Network connection lost.'), { retryable: true })
324
+ },
325
+ }
326
+ },
327
+ } satisfies Store.durableObject.Namespace
328
+ const store = Store.durableObject(namespace, { name: 'tempo-api' })
329
+
330
+ await expect(Store.increment(store, 'counter')).rejects.toThrowErrorMatchingInlineSnapshot(
331
+ `[Error: Network connection lost.]`,
332
+ )
333
+ expect(attempts).toMatchInlineSnapshot(`2`)
334
+ })
335
+
336
+ test('does not retry non-retryable or overloaded stub errors', async () => {
337
+ let attempts = 0
338
+ const namespaceFor = (error: Error) =>
339
+ ({
340
+ getByName() {
341
+ return {
342
+ ...Store.memory(),
343
+ increment: () => {
344
+ attempts += 1
345
+ throw error
346
+ },
347
+ }
348
+ },
349
+ }) satisfies Store.durableObject.Namespace
350
+
351
+ const plain = Store.durableObject(namespaceFor(new Error('boom')), { name: 'tempo-api' })
352
+ await expect(Store.increment(plain, 'counter')).rejects.toThrowErrorMatchingInlineSnapshot(
353
+ `[Error: boom]`,
354
+ )
355
+ expect(attempts).toMatchInlineSnapshot(`1`)
356
+
357
+ attempts = 0
358
+ const overloaded = Store.durableObject(
359
+ namespaceFor(
360
+ Object.assign(new Error('Durable Object is overloaded.'), {
361
+ overloaded: true,
362
+ retryable: true,
363
+ }),
364
+ ),
365
+ { name: 'tempo-api' },
366
+ )
367
+ await expect(Store.increment(overloaded, 'counter')).rejects.toThrowErrorMatchingInlineSnapshot(
368
+ `[Error: Durable Object is overloaded.]`,
369
+ )
370
+ expect(attempts).toMatchInlineSnapshot(`1`)
371
+ })
372
+
289
373
  test('defers missing namespace failures until an operation is attempted', async () => {
290
374
  const store = Store.durableObject(undefined as unknown as Store.durableObject.Namespace, {
291
375
  name: 'tempo-api',
@@ -430,29 +430,48 @@ function durableObjectStub(
430
430
 
431
431
  return from({
432
432
  async delete(key) {
433
- await stub(key).delete(key)
433
+ await durableObjectRetry(() => stub(key).delete(key))
434
434
  },
435
435
  async get(key) {
436
- return stub(key).get(key)
436
+ return durableObjectRetry(() => stub(key).get(key))
437
437
  },
438
438
  // Native RPC increment: the read-modify-write runs inside the object
439
439
  // (one round trip, atomic) instead of the get + put fallback (two).
440
440
  async increment(key, options) {
441
- return stub(key).increment(key, options)
441
+ return durableObjectRetry(() => stub(key).increment(key, options))
442
442
  },
443
443
  async list(options) {
444
444
  // A sharded store scatters keys across objects, so no single object can
445
445
  // enumerate them.
446
446
  if (typeof name === 'function')
447
447
  throw new TypeError('cannot list a sharded Durable Object store')
448
- return stub('').list(options)
448
+ return durableObjectRetry(() => stub('').list(options))
449
449
  },
450
450
  async put(key, value, options) {
451
- await stub(key).put(key, value, options)
451
+ await durableObjectRetry(() => stub(key).put(key, value, options))
452
452
  },
453
453
  })
454
454
  }
455
455
 
456
+ /**
457
+ * Runs a Durable Object stub call, retrying once when workerd marks the error
458
+ * retryable (transient transport failures, e.g. "Network connection lost.")
459
+ * and not overloaded. Callers pass a thunk so the retry resolves a fresh
460
+ * stub: a stub that threw is disconnected.
461
+ */
462
+ async function durableObjectRetry<value>(call: () => Promise<value>): Promise<value> {
463
+ try {
464
+ return await call()
465
+ } catch (error) {
466
+ const { overloaded, retryable } = (error ?? {}) as {
467
+ overloaded?: boolean | undefined
468
+ retryable?: boolean | undefined
469
+ }
470
+ if (retryable !== true || overloaded === true) throw error
471
+ return call()
472
+ }
473
+ }
474
+
456
475
  function cloudflareKvExpirationTtl(ttl: number | undefined) {
457
476
  if (ttl === undefined) return undefined
458
477
  if (ttl <= 0) return 0
@@ -130,7 +130,8 @@ export declare namespace getClient {
130
130
  Chain,
131
131
  undefined,
132
132
  undefined,
133
- PublicActions<HttpTransport, Chain, undefined> & TempoActions<Chain, undefined>
133
+ // Omit core's ERC-20 `token` namespace; `tempoActions` shadows it at runtime.
134
+ Omit<PublicActions<HttpTransport, Chain, undefined>, 'token'> & TempoActions<Chain, undefined>
134
135
  >
135
136
 
136
137
  /** Options for getting a Tempo viem client. */