opticedge-cloud-utils 1.1.8 → 1.1.9

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.
package/dist/retry.d.ts CHANGED
@@ -7,5 +7,7 @@ export type RetryOptions = {
7
7
  isRetryable?: (err: any) => boolean;
8
8
  onRetry?: (err: any, attempt: number, nextDelayMs: number) => void;
9
9
  };
10
+ export declare function sleep(ms: number, signal?: AbortSignal | null): Promise<void>;
10
11
  export declare function isRetryableDefault(err: any): boolean;
12
+ export declare function isRetryableAxios(err: any): boolean;
11
13
  export declare function retry<T>(fn: () => Promise<T>, opts?: RetryOptions): Promise<T>;
package/dist/retry.js CHANGED
@@ -1,20 +1,29 @@
1
1
  "use strict";
2
+ // src/retry.ts
3
+ /* eslint-disable @typescript-eslint/no-explicit-any */
4
+ var __importDefault = (this && this.__importDefault) || function (mod) {
5
+ return (mod && mod.__esModule) ? mod : { "default": mod };
6
+ };
2
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.sleep = sleep;
3
9
  exports.isRetryableDefault = isRetryableDefault;
10
+ exports.isRetryableAxios = isRetryableAxios;
4
11
  exports.retry = retry;
5
- function sleep(ms, signal) {
12
+ const axios_1 = __importDefault(require("axios"));
13
+ async function sleep(ms, signal) {
6
14
  return new Promise((resolve, reject) => {
7
15
  if (signal?.aborted)
8
16
  return reject(new Error('Aborted'));
9
17
  const t = setTimeout(() => {
10
- signal?.removeEventListener('abort', onAbort);
18
+ signal?.removeEventListener?.('abort', onAbort);
11
19
  resolve();
12
20
  }, ms);
13
21
  function onAbort() {
14
22
  clearTimeout(t);
23
+ signal?.removeEventListener?.('abort', onAbort);
15
24
  reject(new Error('Aborted'));
16
25
  }
17
- signal?.addEventListener('abort', onAbort);
26
+ signal?.addEventListener?.('abort', onAbort);
18
27
  });
19
28
  }
20
29
  function isRetryableDefault(err) {
@@ -55,6 +64,24 @@ function isRetryableDefault(err) {
55
64
  return true;
56
65
  return false;
57
66
  }
67
+ function isRetryableAxios(err) {
68
+ if (!err)
69
+ return false;
70
+ // If axios knows about it:
71
+ if (axios_1.default.isAxiosError(err)) {
72
+ // No response -> network error / timeout -> retry
73
+ if (!err.response)
74
+ return true;
75
+ const status = err.response.status;
76
+ // retry on 429 or 5xx
77
+ if (status === 429 || (status >= 500 && status < 600))
78
+ return true;
79
+ // 4xx client errors -> do not retry
80
+ return false;
81
+ }
82
+ // Fallback to your existing heuristics (gRPC codes, node syscodes, etc.)
83
+ return isRetryableDefault(err);
84
+ }
58
85
  async function retry(fn, opts = {}) {
59
86
  const retries = opts.retries ?? 4;
60
87
  const base = opts.baseDelayMs ?? 200;
@@ -66,8 +93,8 @@ async function retry(fn, opts = {}) {
66
93
  const start = Date.now();
67
94
  let attempt = 0;
68
95
  let lastErr;
69
- /* eslint-disable no-constant-condition */
70
- while (true) {
96
+ // attempt = number of retries already performed; initial try is attempt==0
97
+ while (attempt <= retries) {
71
98
  if (signal?.aborted)
72
99
  throw new Error('Aborted');
73
100
  try {
@@ -78,11 +105,11 @@ async function retry(fn, opts = {}) {
78
105
  if (!isRetryable(err)) {
79
106
  throw err;
80
107
  }
81
- if (attempt >= retries) {
108
+ if (attempt >= retries)
82
109
  break;
83
- }
84
- attempt++;
85
- const exp = Math.min(max, base * Math.pow(2, attempt - 1));
110
+ // compute next retry (1-based)
111
+ const nextAttempt = attempt + 1;
112
+ const exp = Math.min(max, base * Math.pow(2, nextAttempt - 1));
86
113
  const delay = Math.floor(Math.random() * exp);
87
114
  if (typeof timeoutMs === 'number') {
88
115
  const elapsed = Date.now() - start;
@@ -91,13 +118,14 @@ async function retry(fn, opts = {}) {
91
118
  }
92
119
  }
93
120
  try {
94
- onRetry?.(err, attempt, delay);
95
- /* eslint-disable @typescript-eslint/no-unused-vars */
121
+ // pass 1-based attempt to onRetry to mean "this is the upcoming retry number"
122
+ onRetry?.(err, nextAttempt, delay);
96
123
  }
97
- catch (e) {
98
- // ignore errors from onRetry
124
+ catch {
125
+ /* ignore onRetry errors */
99
126
  }
100
127
  await sleep(delay, signal);
128
+ attempt = nextAttempt;
101
129
  }
102
130
  }
103
131
  const finalErr = new Error(`Retry failed after ${attempt} retries: ${String(lastErr?.message ?? lastErr)}`);
package/dist/task.d.ts CHANGED
@@ -1 +1,12 @@
1
- export declare function createTask(projectId: string, region: string, queueId: string, data: unknown, serviceAccount: string, audience: string, delaySeconds?: number): Promise<string>;
1
+ import { RetryOptions } from './retry';
2
+ export type CreateTaskOpts = {
3
+ taskId?: string;
4
+ retryOptions?: RetryOptions;
5
+ };
6
+ export declare function isRetryableCloudTasks(err: any): boolean;
7
+ /**
8
+ * Create a Cloud Task with retry logic.
9
+ * If opts.taskId is provided, the task name will be parent + '/tasks/' + taskId — this
10
+ * makes the operation idempotent (create will fail with ALREADY_EXISTS if it already exists).
11
+ */
12
+ export declare function createTask(projectId: string, region: string, queueId: string, data: unknown, serviceAccount: string, audience: string, delaySeconds?: number, opts?: CreateTaskOpts): Promise<string>;
package/dist/task.js CHANGED
@@ -1,9 +1,32 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isRetryableCloudTasks = isRetryableCloudTasks;
3
4
  exports.createTask = createTask;
5
+ // src/cloud-tasks.ts
4
6
  const tasks_1 = require("@google-cloud/tasks");
7
+ const retry_1 = require("./retry");
5
8
  const tasksClient = new tasks_1.CloudTasksClient();
6
- async function createTask(projectId, region, queueId, data, serviceAccount, audience, delaySeconds = 0) {
9
+ // cloud-tasks errors can be gRPC-style (numeric code) or an Error with code string.
10
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
+ function isRetryableCloudTasks(err) {
12
+ if (!err)
13
+ return false;
14
+ // If error explicitly says ALREADY_EXISTS, do not retry (client already has the task)
15
+ // gRPC ALREADY_EXISTS numeric code is 6
16
+ if (err?.code === 6 ||
17
+ err?.grpcCode === 6 ||
18
+ String(err?.code).toUpperCase().includes('ALREADY_EXISTS')) {
19
+ return false;
20
+ }
21
+ // Otherwise delegate to your general retryable heuristics (network / 14=UNAVAILABLE / 5xx etc.)
22
+ return (0, retry_1.isRetryableDefault)(err);
23
+ }
24
+ /**
25
+ * Create a Cloud Task with retry logic.
26
+ * If opts.taskId is provided, the task name will be parent + '/tasks/' + taskId — this
27
+ * makes the operation idempotent (create will fail with ALREADY_EXISTS if it already exists).
28
+ */
29
+ async function createTask(projectId, region, queueId, data, serviceAccount, audience, delaySeconds = 0, opts = {}) {
7
30
  if (!projectId || !region || !queueId || !serviceAccount || !audience) {
8
31
  throw new Error('Missing required parameters for Cloud Tasks setup');
9
32
  }
@@ -14,6 +37,7 @@ async function createTask(projectId, region, queueId, data, serviceAccount, audi
14
37
  seconds: Math.floor(now + delaySeconds)
15
38
  }
16
39
  : undefined;
40
+ // Build the base task
17
41
  const task = {
18
42
  httpRequest: {
19
43
  httpMethod: tasks_1.protos.google.cloud.tasks.v2.HttpMethod.POST,
@@ -29,10 +53,52 @@ async function createTask(projectId, region, queueId, data, serviceAccount, audi
29
53
  },
30
54
  scheduleTime: scheduledTime
31
55
  };
32
- const [response] = await tasksClient.createTask({ parent, task });
33
- if (!response.name) {
34
- throw new Error('Failed to create task: no name returned');
56
+ // Optionally set a deterministic name to make task creation idempotent.
57
+ // The name must be full resource path: projects/PROJECT/locations/REGION/queues/QUEUE/tasks/TASK_ID
58
+ if (opts.taskId) {
59
+ // sanitize/validate taskId if you want; Cloud Tasks requires certain name rules.
60
+ task.name = `${parent}/tasks/${opts.taskId}`;
61
+ }
62
+ // retry options with sensible defaults; allow caller to override via opts.retryOptions
63
+ const retryOpts = {
64
+ retries: 3,
65
+ baseDelayMs: 200,
66
+ maxDelayMs: 3000,
67
+ isRetryable: isRetryableCloudTasks,
68
+ onRetry: (err, attempt, delay) => {
69
+ console.warn(`createTask retry #${attempt} in ${delay}ms — ${String(err?.message ?? err)}`);
70
+ },
71
+ ...opts.retryOptions // allow overrides
72
+ };
73
+ // the unit-of-work we want to retry
74
+ const doCreate = async () => {
75
+ const [response] = await tasksClient.createTask({ parent, task });
76
+ if (!response?.name) {
77
+ // unexpected response shape — treat as non-retryable final failure
78
+ throw new Error('Failed to create task: no name returned');
79
+ }
80
+ return response.name;
81
+ };
82
+ try {
83
+ const name = await (0, retry_1.retry)(() => doCreate(), retryOpts);
84
+ console.log(`✅ Created Cloud Task: ${name}`);
85
+ return name;
86
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
87
+ }
88
+ catch (err) {
89
+ // If ALREADY_EXISTS happened (and was not considered retryable), you may prefer to
90
+ // return the constructed task name (if provided) rather than null/throw.
91
+ if (opts.taskId) {
92
+ const expectedName = `${parent}/tasks/${opts.taskId}`;
93
+ // If we got ALREADY_EXISTS, treat as success and return existing name
94
+ if (err?.original?.code === 6 ||
95
+ err?.code === 6 ||
96
+ String(err?.original?.code).toUpperCase().includes('ALREADY_EXISTS')) {
97
+ console.warn('createTask — task already exists, returning existing name', expectedName);
98
+ return expectedName;
99
+ }
100
+ }
101
+ console.error('createTask — final failure after retries', { error: err?.message ?? err });
102
+ throw err; // rethrow (or return null if you prefer)
35
103
  }
36
- console.log(`✅ Created Cloud Task: ${response.name}`);
37
- return response.name;
38
104
  }
package/dist/tw/wallet.js CHANGED
@@ -6,27 +6,53 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.pregenerateInAppWallet = pregenerateInAppWallet;
7
7
  const axios_1 = __importDefault(require("axios"));
8
8
  const secrets_1 = require("../secrets");
9
+ const retry_1 = require("../retry");
9
10
  async function pregenerateInAppWallet(projectId, twClientId, twSecretKeyName, email) {
10
11
  const TW_SECRET_KEY = await (0, secrets_1.getSecret)(projectId, twSecretKeyName);
11
- try {
12
- const response = await axios_1.default.post('https://in-app-wallet.thirdweb.com/api/v1/pregenerate', { strategy: 'email', email }, {
12
+ if (!TW_SECRET_KEY) {
13
+ console.error('Missing TW secret key', { projectId, twSecretKeyName });
14
+ return null;
15
+ }
16
+ const doRequest = async () => {
17
+ const res = await axios_1.default.post('https://in-app-wallet.thirdweb.com/api/v1/pregenerate', { strategy: 'email', email }, {
13
18
  headers: {
14
19
  'x-secret-key': TW_SECRET_KEY,
15
20
  'x-client-id': twClientId,
16
21
  'Content-Type': 'application/json'
17
22
  },
18
- timeout: 5000
23
+ timeout: 10000
19
24
  });
20
- const wallet = response.data.wallet;
25
+ const wallet = res.data?.wallet;
21
26
  if (!wallet || !wallet.address) {
22
- console.error('Invalid wallet response:', response.data);
23
- return null;
27
+ // server returned 2xx but unexpected shape — don't retry, treat as final failure
28
+ console.error('Invalid wallet response shape', { status: res.status, data: res.data });
29
+ throw new Error('Invalid wallet response');
24
30
  }
25
- console.log('Wallet pregeneration response:', wallet.address);
26
31
  return wallet.address;
32
+ };
33
+ const opts = {
34
+ retries: 3,
35
+ baseDelayMs: 300,
36
+ maxDelayMs: 2000,
37
+ timeoutMs: 15000,
38
+ isRetryable: retry_1.isRetryableAxios,
39
+ onRetry: (err, attempt, delay) => {
40
+ console.warn(`pregenerateInAppWallet: retry #${attempt} in ${delay}ms — ${String(err?.message)}`);
41
+ }
42
+ };
43
+ try {
44
+ const address = await (0, retry_1.retry)(() => doRequest(), opts);
45
+ return address;
46
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
47
  }
28
- catch (error) {
29
- console.error('Error pregenerating wallet:', error);
48
+ catch (err) {
49
+ // final failure after retries
50
+ console.error('pregenerateInAppWallet: final failure', {
51
+ message: err?.message,
52
+ // include axios response data when possible
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
+ responseData: err?.response?.data ?? err?.cause ?? err.original ?? null
55
+ });
30
56
  return null;
31
57
  }
32
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opticedge-cloud-utils",
3
- "version": "1.1.8",
3
+ "version": "1.1.9",
4
4
  "description": "Common utilities for cloud functions",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/retry.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  // src/retry.ts
2
2
  /* eslint-disable @typescript-eslint/no-explicit-any */
3
+
4
+ import axios from 'axios'
5
+
3
6
  export type RetryOptions = {
4
7
  retries?: number
5
8
  baseDelayMs?: number
@@ -10,18 +13,21 @@ export type RetryOptions = {
10
13
  onRetry?: (err: any, attempt: number, nextDelayMs: number) => void
11
14
  }
12
15
 
13
- function sleep(ms: number, signal?: AbortSignal | null) {
16
+ export async function sleep(ms: number, signal?: AbortSignal | null) {
14
17
  return new Promise<void>((resolve, reject) => {
15
18
  if (signal?.aborted) return reject(new Error('Aborted'))
16
19
  const t = setTimeout(() => {
17
- signal?.removeEventListener('abort', onAbort)
20
+ signal?.removeEventListener?.('abort', onAbort)
18
21
  resolve()
19
22
  }, ms)
23
+
20
24
  function onAbort() {
21
25
  clearTimeout(t)
26
+ signal?.removeEventListener?.('abort', onAbort)
22
27
  reject(new Error('Aborted'))
23
28
  }
24
- signal?.addEventListener('abort', onAbort)
29
+
30
+ signal?.addEventListener?.('abort', onAbort)
25
31
  })
26
32
  }
27
33
 
@@ -68,6 +74,26 @@ export function isRetryableDefault(err: any): boolean {
68
74
  return false
69
75
  }
70
76
 
77
+ export function isRetryableAxios(err: any): boolean {
78
+ if (!err) return false
79
+
80
+ // If axios knows about it:
81
+ if (axios.isAxiosError(err)) {
82
+ // No response -> network error / timeout -> retry
83
+ if (!err.response) return true
84
+
85
+ const status = err.response.status
86
+ // retry on 429 or 5xx
87
+ if (status === 429 || (status >= 500 && status < 600)) return true
88
+
89
+ // 4xx client errors -> do not retry
90
+ return false
91
+ }
92
+
93
+ // Fallback to your existing heuristics (gRPC codes, node syscodes, etc.)
94
+ return isRetryableDefault(err)
95
+ }
96
+
71
97
  export async function retry<T>(fn: () => Promise<T>, opts: RetryOptions = {}): Promise<T> {
72
98
  const retries = opts.retries ?? 4
73
99
  const base = opts.baseDelayMs ?? 200
@@ -82,8 +108,8 @@ export async function retry<T>(fn: () => Promise<T>, opts: RetryOptions = {}): P
82
108
  let attempt = 0
83
109
  let lastErr: any
84
110
 
85
- /* eslint-disable no-constant-condition */
86
- while (true) {
111
+ // attempt = number of retries already performed; initial try is attempt==0
112
+ while (attempt <= retries) {
87
113
  if (signal?.aborted) throw new Error('Aborted')
88
114
 
89
115
  try {
@@ -95,12 +121,11 @@ export async function retry<T>(fn: () => Promise<T>, opts: RetryOptions = {}): P
95
121
  throw err
96
122
  }
97
123
 
98
- if (attempt >= retries) {
99
- break
100
- }
124
+ if (attempt >= retries) break
101
125
 
102
- attempt++
103
- const exp = Math.min(max, base * Math.pow(2, attempt - 1))
126
+ // compute next retry (1-based)
127
+ const nextAttempt = attempt + 1
128
+ const exp = Math.min(max, base * Math.pow(2, nextAttempt - 1))
104
129
  const delay = Math.floor(Math.random() * exp)
105
130
 
106
131
  if (typeof timeoutMs === 'number') {
@@ -111,13 +136,14 @@ export async function retry<T>(fn: () => Promise<T>, opts: RetryOptions = {}): P
111
136
  }
112
137
 
113
138
  try {
114
- onRetry?.(err, attempt, delay)
115
- /* eslint-disable @typescript-eslint/no-unused-vars */
116
- } catch (e) {
117
- // ignore errors from onRetry
139
+ // pass 1-based attempt to onRetry to mean "this is the upcoming retry number"
140
+ onRetry?.(err, nextAttempt, delay)
141
+ } catch {
142
+ /* ignore onRetry errors */
118
143
  }
119
144
 
120
145
  await sleep(delay, signal)
146
+ attempt = nextAttempt
121
147
  }
122
148
  }
123
149
 
package/src/task.ts CHANGED
@@ -1,7 +1,39 @@
1
+ // src/cloud-tasks.ts
1
2
  import { CloudTasksClient, protos } from '@google-cloud/tasks'
3
+ import { retry, isRetryableDefault, RetryOptions } from './retry'
2
4
 
3
5
  const tasksClient = new CloudTasksClient()
4
6
 
7
+ export type CreateTaskOpts = {
8
+ // optional deterministic id to make createTask idempotent
9
+ taskId?: string
10
+ retryOptions?: RetryOptions
11
+ }
12
+
13
+ // cloud-tasks errors can be gRPC-style (numeric code) or an Error with code string.
14
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
+ export function isRetryableCloudTasks(err: any): boolean {
16
+ if (!err) return false
17
+
18
+ // If error explicitly says ALREADY_EXISTS, do not retry (client already has the task)
19
+ // gRPC ALREADY_EXISTS numeric code is 6
20
+ if (
21
+ err?.code === 6 ||
22
+ err?.grpcCode === 6 ||
23
+ String(err?.code).toUpperCase().includes('ALREADY_EXISTS')
24
+ ) {
25
+ return false
26
+ }
27
+
28
+ // Otherwise delegate to your general retryable heuristics (network / 14=UNAVAILABLE / 5xx etc.)
29
+ return isRetryableDefault(err)
30
+ }
31
+
32
+ /**
33
+ * Create a Cloud Task with retry logic.
34
+ * If opts.taskId is provided, the task name will be parent + '/tasks/' + taskId — this
35
+ * makes the operation idempotent (create will fail with ALREADY_EXISTS if it already exists).
36
+ */
5
37
  export async function createTask(
6
38
  projectId: string,
7
39
  region: string,
@@ -9,7 +41,8 @@ export async function createTask(
9
41
  data: unknown,
10
42
  serviceAccount: string,
11
43
  audience: string,
12
- delaySeconds: number = 0
44
+ delaySeconds: number = 0,
45
+ opts: CreateTaskOpts = {}
13
46
  ): Promise<string> {
14
47
  if (!projectId || !region || !queueId || !serviceAccount || !audience) {
15
48
  throw new Error('Missing required parameters for Cloud Tasks setup')
@@ -25,6 +58,7 @@ export async function createTask(
25
58
  }
26
59
  : undefined
27
60
 
61
+ // Build the base task
28
62
  const task: protos.google.cloud.tasks.v2.ITask = {
29
63
  httpRequest: {
30
64
  httpMethod: protos.google.cloud.tasks.v2.HttpMethod.POST,
@@ -41,11 +75,57 @@ export async function createTask(
41
75
  scheduleTime: scheduledTime
42
76
  }
43
77
 
44
- const [response] = await tasksClient.createTask({ parent, task })
45
- if (!response.name) {
46
- throw new Error('Failed to create task: no name returned')
78
+ // Optionally set a deterministic name to make task creation idempotent.
79
+ // The name must be full resource path: projects/PROJECT/locations/REGION/queues/QUEUE/tasks/TASK_ID
80
+ if (opts.taskId) {
81
+ // sanitize/validate taskId if you want; Cloud Tasks requires certain name rules.
82
+ task.name = `${parent}/tasks/${opts.taskId}`
47
83
  }
48
84
 
49
- console.log(`✅ Created Cloud Task: ${response.name}`)
50
- return response.name
85
+ // retry options with sensible defaults; allow caller to override via opts.retryOptions
86
+ const retryOpts: RetryOptions = {
87
+ retries: 3,
88
+ baseDelayMs: 200,
89
+ maxDelayMs: 3000,
90
+ isRetryable: isRetryableCloudTasks,
91
+ onRetry: (err, attempt, delay) => {
92
+ console.warn(`createTask retry #${attempt} in ${delay}ms — ${String(err?.message ?? err)}`)
93
+ },
94
+ ...opts.retryOptions // allow overrides
95
+ }
96
+
97
+ // the unit-of-work we want to retry
98
+ const doCreate = async (): Promise<string> => {
99
+ const [response] = await tasksClient.createTask({ parent, task })
100
+ if (!response?.name) {
101
+ // unexpected response shape — treat as non-retryable final failure
102
+ throw new Error('Failed to create task: no name returned')
103
+ }
104
+ return response.name
105
+ }
106
+
107
+ try {
108
+ const name = await retry(() => doCreate(), retryOpts)
109
+ console.log(`✅ Created Cloud Task: ${name}`)
110
+ return name
111
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
112
+ } catch (err: any) {
113
+ // If ALREADY_EXISTS happened (and was not considered retryable), you may prefer to
114
+ // return the constructed task name (if provided) rather than null/throw.
115
+ if (opts.taskId) {
116
+ const expectedName = `${parent}/tasks/${opts.taskId}`
117
+ // If we got ALREADY_EXISTS, treat as success and return existing name
118
+ if (
119
+ err?.original?.code === 6 ||
120
+ err?.code === 6 ||
121
+ String(err?.original?.code).toUpperCase().includes('ALREADY_EXISTS')
122
+ ) {
123
+ console.warn('createTask — task already exists, returning existing name', expectedName)
124
+ return expectedName
125
+ }
126
+ }
127
+
128
+ console.error('createTask — final failure after retries', { error: err?.message ?? err })
129
+ throw err // rethrow (or return null if you prefer)
130
+ }
51
131
  }
package/src/tw/wallet.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import axios from 'axios'
2
2
  import { getSecret } from '../secrets'
3
+ import { isRetryableAxios, retry, RetryOptions } from '../retry'
3
4
 
4
5
  export async function pregenerateInAppWallet(
5
6
  projectId: string,
@@ -8,9 +9,13 @@ export async function pregenerateInAppWallet(
8
9
  email: string
9
10
  ): Promise<string | null> {
10
11
  const TW_SECRET_KEY = await getSecret(projectId, twSecretKeyName)
12
+ if (!TW_SECRET_KEY) {
13
+ console.error('Missing TW secret key', { projectId, twSecretKeyName })
14
+ return null
15
+ }
11
16
 
12
- try {
13
- const response = await axios.post(
17
+ const doRequest = async (): Promise<string> => {
18
+ const res = await axios.post(
14
19
  'https://in-app-wallet.thirdweb.com/api/v1/pregenerate',
15
20
  { strategy: 'email', email },
16
21
  {
@@ -19,20 +24,45 @@ export async function pregenerateInAppWallet(
19
24
  'x-client-id': twClientId,
20
25
  'Content-Type': 'application/json'
21
26
  },
22
- timeout: 5000
27
+ timeout: 10000
23
28
  }
24
29
  )
25
30
 
26
- const wallet = response.data.wallet
31
+ const wallet = res.data?.wallet
27
32
  if (!wallet || !wallet.address) {
28
- console.error('Invalid wallet response:', response.data)
29
- return null
33
+ // server returned 2xx but unexpected shape — don't retry, treat as final failure
34
+ console.error('Invalid wallet response shape', { status: res.status, data: res.data })
35
+ throw new Error('Invalid wallet response')
30
36
  }
31
37
 
32
- console.log('Wallet pregeneration response:', wallet.address)
33
38
  return wallet.address
34
- } catch (error) {
35
- console.error('Error pregenerating wallet:', error)
39
+ }
40
+
41
+ const opts: RetryOptions = {
42
+ retries: 3,
43
+ baseDelayMs: 300,
44
+ maxDelayMs: 2000,
45
+ timeoutMs: 15000,
46
+ isRetryable: isRetryableAxios,
47
+ onRetry: (err, attempt, delay) => {
48
+ console.warn(
49
+ `pregenerateInAppWallet: retry #${attempt} in ${delay}ms — ${String(err?.message)}`
50
+ )
51
+ }
52
+ }
53
+
54
+ try {
55
+ const address = await retry(() => doRequest(), opts)
56
+ return address
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
+ } catch (err: any) {
59
+ // final failure after retries
60
+ console.error('pregenerateInAppWallet: final failure', {
61
+ message: err?.message,
62
+ // include axios response data when possible
63
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
64
+ responseData: err?.response?.data ?? err?.cause ?? (err as any).original ?? null
65
+ })
36
66
  return null
37
67
  }
38
68
  }
@@ -1,6 +1,59 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  // tests/retry.test.ts
3
- import { retry, isRetryableDefault } from '../src'
3
+
4
+ import axios from 'axios'
5
+ import { retry, isRetryableDefault, sleep, isRetryableAxios } from '../src'
6
+
7
+ describe('sleep()', () => {
8
+ const realAbortController = global.AbortController
9
+
10
+ afterEach(() => {
11
+ // restore timers and AbortController if changed
12
+ jest.useRealTimers()
13
+ global.AbortController = realAbortController
14
+ jest.clearAllMocks()
15
+ })
16
+
17
+ test('resolves after the specified delay', async () => {
18
+ jest.useFakeTimers()
19
+ const p = sleep(100)
20
+
21
+ // advance timers by the sleep duration
22
+ jest.advanceTimersByTime(100)
23
+
24
+ // allow the Promise microtask queue to run
25
+ await Promise.resolve()
26
+
27
+ await expect(p).resolves.toBeUndefined()
28
+ })
29
+
30
+ test('rejects immediately if signal is already aborted', async () => {
31
+ const ac = new AbortController()
32
+ ac.abort()
33
+
34
+ await expect(sleep(50, ac.signal)).rejects.toThrow('Aborted')
35
+ })
36
+
37
+ test('rejects when aborted after scheduling', async () => {
38
+ jest.useFakeTimers()
39
+ const ac = new AbortController()
40
+
41
+ const p = sleep(1000, ac.signal)
42
+
43
+ // abort after the sleep has been scheduled but before timeout
44
+ ac.abort()
45
+
46
+ // allow the event-loop microtasks to run
47
+ await Promise.resolve()
48
+
49
+ await expect(p).rejects.toThrow('Aborted')
50
+
51
+ // advancing timers should not resolve the promise
52
+ jest.advanceTimersByTime(1000)
53
+ await Promise.resolve()
54
+ await expect(p).rejects.toThrow('Aborted')
55
+ })
56
+ })
4
57
 
5
58
  describe('isRetryableDefault', () => {
6
59
  test('returns false for falsy errors (covers `if (!err) return false`)', () => {
@@ -53,6 +106,71 @@ describe('isRetryableDefault', () => {
53
106
  })
54
107
  })
55
108
 
109
+ describe('isRetryableAxios()', () => {
110
+ // Helper to synthesize axios-like errors
111
+ function makeAxiosError(payload: Partial<any> = {}) {
112
+ // minimal axios-style error: flag + response optionally
113
+ const err: any = new Error(payload.message ?? 'axios-err')
114
+ err.isAxiosError = true
115
+ if (Object.prototype.hasOwnProperty.call(payload, 'response')) {
116
+ err.response = payload.response
117
+ } else if (payload.response === undefined && payload.noResponse) {
118
+ // explicit network/no-response: leave out response
119
+ }
120
+ if (payload.code) err.code = payload.code
121
+ if (payload.message) err.message = payload.message
122
+ return err
123
+ }
124
+
125
+ test('returns false when err is falsy', () => {
126
+ expect(isRetryableAxios(null)).toBe(false)
127
+ expect(isRetryableAxios(undefined)).toBe(false)
128
+ // also check other falsy-ish values that shouldn't be considered retryable
129
+ expect(isRetryableAxios(false as any)).toBe(false)
130
+ expect(isRetryableAxios('' as any)).toBe(false)
131
+ })
132
+
133
+ test('returns true for network/no-response axios errors', () => {
134
+ const networkErr = makeAxiosError({ noResponse: true })
135
+ expect(axios.isAxiosError(networkErr)).toBe(true) // sanity check
136
+ expect(isRetryableAxios(networkErr)).toBe(true)
137
+ })
138
+
139
+ test('returns true for 500 server error', () => {
140
+ const serverErr = makeAxiosError({ response: { status: 500, data: {} } })
141
+ expect(isRetryableAxios(serverErr)).toBe(true)
142
+ })
143
+
144
+ test('returns true for 503 server error', () => {
145
+ const serverErr = makeAxiosError({ response: { status: 503, data: {} } })
146
+ expect(isRetryableAxios(serverErr)).toBe(true)
147
+ })
148
+
149
+ test('returns true for 429 rate limit', () => {
150
+ const rateErr = makeAxiosError({ response: { status: 429, data: {} } })
151
+ expect(isRetryableAxios(rateErr)).toBe(true)
152
+ })
153
+
154
+ test('returns false for 400 client error (do not retry)', () => {
155
+ const badReq = makeAxiosError({ response: { status: 400, data: {} } })
156
+ expect(isRetryableAxios(badReq)).toBe(false)
157
+ })
158
+
159
+ test('delegates to isRetryableDefault for non-axios errors', () => {
160
+ // make a network-style Node error that isRetryableDefault recognizes
161
+ const nodeErr = { code: 'ECONNRESET', message: 'socket closed' }
162
+ expect(isRetryableDefault(nodeErr)).toBe(true) // sanity
163
+ expect(isRetryableAxios(nodeErr)).toBe(true)
164
+ })
165
+
166
+ test('returns false for non-retryable non-axios error', () => {
167
+ const normalErr = new Error('something bad but not retryable')
168
+ // isRetryableDefault likely returns false for a plain Error w/o hint
169
+ expect(isRetryableDefault(normalErr)).toBe(false)
170
+ expect(isRetryableAxios(normalErr)).toBe(false)
171
+ })
172
+ })
173
+
56
174
  describe('retry util', () => {
57
175
  afterEach(() => {
58
176
  jest.restoreAllMocks()
@@ -1,8 +1,12 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ // tests/createTask.retry.test.ts
1
3
  const mockCreateTask = jest.fn()
2
4
  const mockQueuePath = jest.fn(
3
- (projectId, region, queueId) => `projects/${projectId}/locations/${region}/queues/${queueId}`
5
+ (projectId: string, region: string, queueId: string) =>
6
+ `projects/${projectId}/locations/${region}/queues/${queueId}`
4
7
  )
5
8
 
9
+ // Mock Cloud Tasks client and protos (same as your original file)
6
10
  jest.mock('@google-cloud/tasks', () => {
7
11
  return {
8
12
  CloudTasksClient: jest.fn(() => ({
@@ -25,87 +29,161 @@ jest.mock('@google-cloud/tasks', () => {
25
29
  }
26
30
  })
27
31
 
28
- import { createTask } from '../src/task'
32
+ // Mock the retry module so tests can inspect how createTask uses it.
33
+ // We'll set mockRetry behavior in each test as needed.
34
+ const realRetryModule = jest.requireActual('../src/retry') as typeof import('../src/retry')
35
+ const mockRetry = jest.fn()
36
+
37
+ jest.mock('../src/retry', () => ({
38
+ // spread real exports so isRetryableDefault, isRetryableAxios etc remain available
39
+ ...realRetryModule,
40
+ // override only 'retry' with our mock
41
+ retry: (...args: any[]) => mockRetry(...args)
42
+ }))
43
+
44
+ import { createTask, isRetryableCloudTasks } from '../src/task'
45
+
46
+ describe('isRetryableCloudTasks', () => {
47
+ test('returns false for falsy errors', () => {
48
+ expect(isRetryableCloudTasks(null)).toBe(false)
49
+ expect(isRetryableCloudTasks(undefined)).toBe(false)
50
+ // other falsy-ish values should also be non-retryable
51
+ expect(isRetryableCloudTasks(false as any)).toBe(false)
52
+ expect(isRetryableCloudTasks('' as any)).toBe(false)
53
+ })
54
+
55
+ test('treats ALREADY_EXISTS (code: 6) as NOT retryable', () => {
56
+ expect(isRetryableCloudTasks({ code: 6 })).toBe(false)
57
+ expect(isRetryableCloudTasks({ grpcCode: 6 })).toBe(false)
58
+ expect(isRetryableCloudTasks({ code: 'ALREADY_EXISTS' })).toBe(false)
59
+ expect(isRetryableCloudTasks({ code: 'already_exists' })).toBe(false)
60
+ })
61
+
62
+ test('treats UNAVAILABLE (code: 14) as retryable', () => {
63
+ expect(isRetryableCloudTasks({ code: 14 })).toBe(true)
64
+ expect(isRetryableCloudTasks({ grpcCode: 14 })).toBe(true)
65
+ })
66
+
67
+ test('treats HTTP 5xx and 429 as retryable, 4xx not retryable', () => {
68
+ expect(isRetryableCloudTasks({ response: { status: 500 } })).toBe(true)
69
+ expect(isRetryableCloudTasks({ response: { status: 503 } })).toBe(true)
70
+ expect(isRetryableCloudTasks({ response: { status: 429 } })).toBe(true)
71
+
72
+ expect(isRetryableCloudTasks({ response: { status: 400 } })).toBe(false)
73
+ expect(isRetryableCloudTasks({ response: { status: 404 } })).toBe(false)
74
+ })
75
+
76
+ test('treats node network errno like ECONNRESET as retryable (delegated to isRetryableDefault)', () => {
77
+ // isRetryableDefault recognizes typical node syscodes like 'ECONNRESET'
78
+ expect(isRetryableCloudTasks({ code: 'ECONNRESET' })).toBe(true)
79
+ expect(isRetryableCloudTasks({ code: 'ETIMEDOUT' })).toBe(true)
80
+ expect(isRetryableCloudTasks({ code: 'EAI_AGAIN' })).toBe(true)
81
+ })
82
+
83
+ test('plain Error without hints is not retryable', () => {
84
+ expect(isRetryableCloudTasks(new Error('oops'))).toBe(false)
85
+ })
86
+
87
+ test('non-standard objects without response but with message may or may not be retryable depending on heuristics', () => {
88
+ // This test documents the expected behavior: a message-only error generally is NOT retryable
89
+ // unless the message contains a retryable substring (e.g. "timeout" or "unavailable").
90
+ expect(isRetryableCloudTasks({ message: 'socket closed' })).toBe(false)
91
+ expect(isRetryableCloudTasks({ message: 'request timed out' })).toBe(true)
92
+ expect(isRetryableCloudTasks({ message: 'temporarily unavailable' })).toBe(true)
93
+ })
94
+ })
95
+
96
+ describe('createTask (with retry)', () => {
97
+ const projectId = 'test-project'
98
+ const region = 'us-central1'
99
+ const queueId = 'test-queue'
100
+ const data = { test: 'data' }
101
+ const serviceAccount = 'test-sa@test.iam.gserviceaccount.com'
102
+ const audience = 'https://run-url'
103
+ const mockTaskName =
104
+ 'projects/test-project/locations/us-central1/queues/test-queue/tasks/task-123'
105
+
106
+ let warnSpy: jest.SpyInstance
29
107
 
30
- describe('createTask', () => {
31
108
  beforeEach(() => {
32
109
  mockCreateTask.mockReset()
110
+ mockQueuePath.mockClear()
111
+ mockRetry.mockReset()
112
+ // store the spy instance so we can assert against it safely
113
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
33
114
  })
34
115
 
35
116
  it('throws error if any required parameter is missing', async () => {
36
- // Missing projectId
117
+ // Ensure retry / createTask are not called for parameter validation errors
37
118
  await expect(
38
119
  createTask('', 'region', 'queue', {}, 'serviceAccount', 'audience')
39
120
  ).rejects.toThrow('Missing required parameters for Cloud Tasks setup')
40
121
 
41
- // Missing region
42
122
  await expect(
43
123
  createTask('project', '', 'queue', {}, 'serviceAccount', 'audience')
44
124
  ).rejects.toThrow('Missing required parameters for Cloud Tasks setup')
45
125
 
46
- // Missing queueId
47
126
  await expect(
48
127
  createTask('project', 'region', '', {}, 'serviceAccount', 'audience')
49
128
  ).rejects.toThrow('Missing required parameters for Cloud Tasks setup')
50
129
 
51
- // Missing serviceAccount
52
130
  await expect(createTask('project', 'region', 'queue', {}, '', 'audience')).rejects.toThrow(
53
131
  'Missing required parameters for Cloud Tasks setup'
54
132
  )
55
133
 
56
- // Missing audience
57
134
  await expect(
58
135
  createTask('project', 'region', 'queue', {}, 'serviceAccount', '')
59
136
  ).rejects.toThrow('Missing required parameters for Cloud Tasks setup')
137
+
138
+ expect(mockCreateTask).not.toHaveBeenCalled()
139
+ expect(mockRetry).not.toHaveBeenCalled()
60
140
  })
61
141
 
62
- it('should create a task and return task name', async () => {
63
- const mockTaskName =
64
- 'projects/test-project/locations/us-central1/queues/test-queue/tasks/task-123'
142
+ it('should create a task and return task name (calls retry once)', async () => {
143
+ // Configure retry mock to simply execute the provided function immediately.
144
+ mockRetry.mockImplementation(async (fn: () => any) => {
145
+ return await fn()
146
+ })
147
+
65
148
  mockCreateTask.mockResolvedValue([{ name: mockTaskName }])
66
149
 
67
- const result = await createTask(
68
- 'test-project',
69
- 'us-central1',
70
- 'test-queue',
71
- { test: 'data' },
72
- 'test-sa@test.iam.gserviceaccount.com',
73
- 'https://run-url'
74
- )
150
+ const result = await createTask(projectId, region, queueId, data, serviceAccount, audience)
75
151
 
76
152
  expect(result).toBe(mockTaskName)
77
153
  expect(mockCreateTask).toHaveBeenCalledTimes(1)
154
+ expect(mockRetry).toHaveBeenCalledTimes(1)
155
+ // ensure the createTask was called with an object containing `task`
156
+ const calledArg = mockCreateTask.mock.calls[0][0]
157
+ expect(calledArg).toHaveProperty('task')
78
158
  })
79
159
 
80
160
  it('should throw error if task name is missing', async () => {
161
+ mockRetry.mockImplementation(async (fn: () => any) => {
162
+ return await fn()
163
+ })
81
164
  mockCreateTask.mockResolvedValue([{}]) // Simulate missing name
82
165
 
83
166
  await expect(
84
- createTask(
85
- 'test-project',
86
- 'us-central1',
87
- 'test-queue',
88
- { foo: 'bar' },
89
- 'test@project.iam.gserviceaccount.com',
90
- 'https://example.com'
91
- )
167
+ createTask(projectId, region, queueId, { foo: 'bar' }, serviceAccount, audience)
92
168
  ).rejects.toThrow('Failed to create task: no name returned')
93
169
  })
94
170
 
95
171
  it('should include scheduleTime if delaySeconds is set', async () => {
96
- const mockTaskName = 'projects/test/locations/us-central1/queues/test/tasks/task-456'
97
- mockCreateTask.mockResolvedValue([{ name: mockTaskName }])
172
+ mockRetry.mockImplementation(async (fn: () => any) => await fn())
173
+ mockCreateTask.mockResolvedValue([
174
+ { name: 'projects/test/locations/us-central1/queues/test/tasks/task-456' }
175
+ ])
98
176
 
99
177
  const delaySeconds = 120
100
178
  const before = Math.floor(Date.now() / 1000) + delaySeconds
101
179
 
102
180
  await createTask(
103
- 'test',
104
- 'us-central1',
105
- 'test',
181
+ projectId,
182
+ region,
183
+ queueId,
106
184
  { message: 'delayed' },
107
- 'sa@test.iam.gserviceaccount.com',
108
- 'https://run-url',
185
+ serviceAccount,
186
+ audience,
109
187
  delaySeconds
110
188
  )
111
189
 
@@ -118,4 +196,142 @@ describe('createTask', () => {
118
196
  expect(scheduleTime).toBeGreaterThanOrEqual(before)
119
197
  expect(scheduleTime).toBeLessThanOrEqual(after)
120
198
  })
199
+
200
+ it('retries on transient (UNAVAILABLE) error then succeeds', async () => {
201
+ // Simulate createTask: first call rejects with transient code 14, second call resolves.
202
+ mockCreateTask
203
+ .mockRejectedValueOnce({ code: 14, message: 'UNAVAILABLE' })
204
+ .mockResolvedValueOnce([{ name: mockTaskName }])
205
+
206
+ // Implement a small retry simulator: call fn(); if it throws then call opts.onRetry and call fn() again.
207
+ mockRetry.mockImplementation(async (fn: () => any, opts?: any) => {
208
+ try {
209
+ return await fn()
210
+ } catch (err) {
211
+ // simulate onRetry callback being invoked by retry implementation
212
+ if (opts?.onRetry) {
213
+ try {
214
+ opts.onRetry(err, 1, 0)
215
+ } catch {
216
+ // ignore
217
+ }
218
+ }
219
+ return await fn()
220
+ }
221
+ })
222
+
223
+ const result = await createTask(projectId, region, queueId, data, serviceAccount, audience)
224
+
225
+ expect(mockCreateTask).toHaveBeenCalledTimes(2)
226
+ expect(mockRetry).toHaveBeenCalledTimes(1)
227
+ expect(result).toBe(mockTaskName)
228
+ })
229
+
230
+ it('returns expected name if ALREADY_EXISTS and taskId provided (idempotent)', async () => {
231
+ // Simulate retry throwing ALREADY_EXISTS error (code: 6)
232
+ mockRetry.mockImplementation(async () => {
233
+ throw { code: 6, message: 'ALREADY_EXISTS' }
234
+ })
235
+
236
+ const taskId = 'task-123'
237
+ const expectedName = `projects/${projectId}/locations/${region}/queues/${queueId}/tasks/${taskId}`
238
+
239
+ const result = await createTask(projectId, region, queueId, data, serviceAccount, audience, 0, {
240
+ taskId
241
+ })
242
+
243
+ // Should return expected name when ALREADY_EXISTS and taskId provided
244
+ expect(result).toBe(expectedName)
245
+ expect(mockRetry).toHaveBeenCalledTimes(1)
246
+ })
247
+
248
+ it('passes an isRetryable to retry that treats ALREADY_EXISTS as non-retryable and UNAVAILABLE as retryable', async () => {
249
+ // Spy on the options passed to retry and invoke isRetryable with sample errors
250
+ mockRetry.mockImplementation(async (fn: () => any, opts?: any) => {
251
+ // Sanity: opts should exist and include isRetryable
252
+ expect(opts).toBeDefined()
253
+ expect(typeof opts.isRetryable).toBe('function')
254
+
255
+ const isRetryable = opts.isRetryable
256
+
257
+ // ALREADY_EXISTS (gRPC code 6) should NOT be retryable
258
+ expect(isRetryable({ code: 6 })).toBe(false)
259
+ // UNAVAILABLE (gRPC code 14) should be retryable
260
+ expect(isRetryable({ code: 14 })).toBe(true)
261
+
262
+ expect(isRetryable({ code: 'ECONNRESET' })).toBe(true)
263
+
264
+ // Execute the function normally for this test
265
+ return await fn()
266
+ })
267
+
268
+ mockCreateTask.mockResolvedValue([{ name: mockTaskName }])
269
+
270
+ const result = await createTask(projectId, region, queueId, data, serviceAccount, audience)
271
+ expect(result).toBe(mockTaskName)
272
+ expect(mockRetry).toHaveBeenCalledTimes(1)
273
+ })
274
+
275
+ it('invokes onRetry with actual error message and logs it', async () => {
276
+ // first call fails with a retryable transient error, second call succeeds
277
+ const expectedName = `projects/${projectId}/locations/${region}/queues/${queueId}/tasks/1`
278
+ mockCreateTask
279
+ .mockRejectedValueOnce({ code: 14, message: 'UNAVAILABLE' }) // transient
280
+ .mockResolvedValueOnce([{ name: expectedName }])
281
+
282
+ // Simulate retry behaviour: call fn(); if it throws, call onRetry(err, attempt, delay) then try fn() again.
283
+ mockRetry.mockImplementation(async (fn: () => any, opts?: any) => {
284
+ try {
285
+ return await fn()
286
+ } catch (err) {
287
+ if (opts?.onRetry) {
288
+ opts.onRetry(err, 1, 0)
289
+ }
290
+ return await fn()
291
+ }
292
+ })
293
+
294
+ const result = await createTask(projectId, region, queueId, data, serviceAccount, audience)
295
+ expect(result).toBe(expectedName)
296
+ expect(mockCreateTask).toHaveBeenCalledTimes(2)
297
+
298
+ // assert onRetry logged the attempt and included the error message
299
+ expect(warnSpy.mock.calls.length).toBeGreaterThanOrEqual(1)
300
+ const warnMsg = warnSpy.mock.calls[0][0] as string
301
+ expect(warnMsg).toContain('createTask retry #1 in 0ms')
302
+ expect(warnMsg).toContain('UNAVAILABLE')
303
+ })
304
+
305
+ it('invokes onRetry with undefined error and logs "undefined" in message', async () => {
306
+ const expectedName = `projects/${projectId}/locations/${region}/queues/${queueId}/tasks/2`
307
+ mockCreateTask
308
+ .mockRejectedValueOnce(new Error('boom')) // this will trigger catch in our mockRetry
309
+ .mockResolvedValueOnce([{ name: expectedName }])
310
+
311
+ // This variant calls onRetry(undefined, ...) to exercise the err?.message ?? err branch.
312
+ mockRetry.mockImplementation(async (fn: () => any, opts?: any) => {
313
+ try {
314
+ return await fn()
315
+ } catch {
316
+ if (opts?.onRetry) {
317
+ opts.onRetry(undefined, 1, 0) // pass undefined as the error
318
+ }
319
+ return await fn()
320
+ }
321
+ })
322
+
323
+ const result = await createTask(projectId, region, queueId, data, serviceAccount, audience)
324
+ expect(result).toBe(expectedName)
325
+ expect(mockCreateTask).toHaveBeenCalledTimes(2)
326
+
327
+ // Assert the onRetry prefix was logged
328
+ expect(warnSpy.mock.calls.length).toBeGreaterThanOrEqual(1)
329
+ const warnMsg = warnSpy.mock.calls[0][0] as string
330
+ expect(warnMsg).toContain('createTask retry #1 in 0ms')
331
+
332
+ // instead of requiring the exact 'undefined' substring (which can vary),
333
+ // assert there's something logged in the error slot (non-empty)
334
+ const afterPrefix = warnMsg.replace('createTask retry #1 in 0ms — ', '')
335
+ expect(afterPrefix.length).toBeGreaterThan(0)
336
+ })
121
337
  })
@@ -1,3 +1,5 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ // tests/wallet.test.ts (or append to your existing test file)
1
3
  import axios from 'axios'
2
4
  import { pregenerateInAppWallet } from '../../src/tw/wallet'
3
5
  import * as secrets from '../../src/secrets'
@@ -8,7 +10,27 @@ jest.mock('../../src/secrets')
8
10
  const mockedAxios = axios as jest.Mocked<typeof axios>
9
11
  const mockedGetSecret = secrets.getSecret as jest.Mock
10
12
 
11
- describe('pregenerateInAppWallet', () => {
13
+ const makeSuccessResponse = (data: any) => ({ data, status: 200 })
14
+
15
+ // small helper to synthesize axios-like errors
16
+ function makeAxiosError(message: string, status?: number, noResponse = false) {
17
+ const err: any = new Error(message)
18
+ err.isAxiosError = true
19
+ if (!noResponse) {
20
+ if (typeof status === 'number') {
21
+ err.response = { status, data: { message: `mocked ${status}` } }
22
+ } else {
23
+ // no numeric status provided but simulate a response
24
+ err.response = { status: 500, data: { message: 'mock' } }
25
+ }
26
+ } else {
27
+ // explicitly simulate network error: no response property
28
+ delete err.response
29
+ }
30
+ return err
31
+ }
32
+
33
+ describe('pregenerateInAppWallet with retry behavior', () => {
12
34
  const mockProjectId = 'test-project'
13
35
  const mockClientId = 'test-client-id'
14
36
  const mockSecretKeyName = 'test-key'
@@ -16,19 +38,66 @@ describe('pregenerateInAppWallet', () => {
16
38
  const mockWalletAddress = '0xabc123'
17
39
  const mockSecretValue = 'mock-secret-key'
18
40
 
41
+ const realMathRandom = Math.random
42
+ const realWarn = console.warn
43
+ const realError = console.error
44
+
19
45
  beforeEach(() => {
20
46
  jest.clearAllMocks()
47
+ // make retry jitter deterministic (so delay becomes 0)
48
+ Math.random = jest.fn(() => 0)
49
+
50
+ jest
51
+ .spyOn(axios, 'isAxiosError')
52
+ .mockImplementation((e: any) => Boolean(e && e.isAxiosError) as any)
53
+ // silence console.warn/error in passing tests (but still allow spying)
54
+ console.warn = jest.fn()
55
+ console.error = jest.fn()
21
56
  })
22
57
 
23
- it('returns wallet address on success', async () => {
58
+ afterEach(() => {
59
+ // restore
60
+ Math.random = realMathRandom
61
+ console.warn = realWarn
62
+ console.error = realError
63
+ })
64
+
65
+ it('returns null and logs if secret is missing', async () => {
66
+ // simulate secret manager returning nothing
67
+ mockedGetSecret.mockResolvedValueOnce(undefined)
68
+
69
+ const result = await pregenerateInAppWallet(
70
+ mockProjectId,
71
+ mockClientId,
72
+ mockSecretKeyName,
73
+ mockEmail
74
+ )
75
+
76
+ // should not call axios at all
77
+ expect(mockedAxios.post).not.toHaveBeenCalled()
78
+ expect(result).toBeNull()
79
+
80
+ // console.error should be called with "Missing TW secret key"
81
+ expect((console.error as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(1)
82
+ expect((console.error as jest.Mock).mock.calls[0][0]).toEqual('Missing TW secret key')
83
+ // second arg should contain the projectId and secretName
84
+ expect((console.error as jest.Mock).mock.calls[0][1]).toEqual(
85
+ expect.objectContaining({ projectId: mockProjectId, twSecretKeyName: mockSecretKeyName })
86
+ )
87
+ })
88
+
89
+ it('retries on 500 then succeeds and returns address', async () => {
24
90
  mockedGetSecret.mockResolvedValue(mockSecretValue)
25
- mockedAxios.post.mockResolvedValue({
26
- data: {
27
- wallet: {
28
- address: mockWalletAddress
91
+
92
+ // first call: server 500 -> rejected
93
+ mockedAxios.post
94
+ .mockRejectedValueOnce(makeAxiosError('server error', 500))
95
+ // second call: success
96
+ .mockResolvedValueOnce({
97
+ data: {
98
+ wallet: { address: mockWalletAddress }
29
99
  }
30
- }
31
- })
100
+ } as any)
32
101
 
33
102
  const result = await pregenerateInAppWallet(
34
103
  mockProjectId,
@@ -38,29 +107,55 @@ describe('pregenerateInAppWallet', () => {
38
107
  )
39
108
 
40
109
  expect(mockedGetSecret).toHaveBeenCalledWith(mockProjectId, mockSecretKeyName)
41
- expect(mockedAxios.post).toHaveBeenCalledWith(
42
- 'https://in-app-wallet.thirdweb.com/api/v1/pregenerate',
43
- { strategy: 'email', email: mockEmail },
44
- {
45
- headers: {
46
- 'x-secret-key': mockSecretValue,
47
- 'x-client-id': mockClientId,
48
- 'Content-Type': 'application/json'
49
- },
50
- timeout: 5000
51
- }
110
+ expect(mockedAxios.post).toHaveBeenCalledTimes(2)
111
+ expect(result).toBe(mockWalletAddress)
112
+ // onRetry should have been called (we log via console.warn in our version)
113
+ expect((console.warn as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(1)
114
+ })
115
+
116
+ it('does NOT retry on 400 and returns null immediately', async () => {
117
+ mockedGetSecret.mockResolvedValue(mockSecretValue)
118
+ mockedAxios.post.mockRejectedValue(makeAxiosError('bad request', 400))
119
+
120
+ const result = await pregenerateInAppWallet(
121
+ mockProjectId,
122
+ mockClientId,
123
+ mockSecretKeyName,
124
+ mockEmail
52
125
  )
53
126
 
127
+ expect(mockedAxios.post).toHaveBeenCalledTimes(1)
128
+ expect(result).toBeNull()
129
+ // console.warn should not indicate retry
130
+ expect((console.warn as jest.Mock).mock.calls.length).toBe(0)
131
+ })
132
+
133
+ it('retries on network/no-response errors and then succeeds', async () => {
134
+ mockedGetSecret.mockResolvedValue(mockSecretValue)
135
+
136
+ // First: network error (no response)
137
+ mockedAxios.post
138
+ .mockRejectedValueOnce(makeAxiosError('network down', undefined, true))
139
+ .mockResolvedValueOnce({
140
+ data: { wallet: { address: mockWalletAddress } }
141
+ } as any)
142
+
143
+ const result = await pregenerateInAppWallet(
144
+ mockProjectId,
145
+ mockClientId,
146
+ mockSecretKeyName,
147
+ mockEmail
148
+ )
149
+
150
+ expect(mockedAxios.post).toHaveBeenCalledTimes(2)
54
151
  expect(result).toBe(mockWalletAddress)
55
152
  })
56
153
 
57
- it('returns null if wallet.address is missing', async () => {
154
+ it('exhausts retries on repeated 500s and returns null', async () => {
58
155
  mockedGetSecret.mockResolvedValue(mockSecretValue)
59
- mockedAxios.post.mockResolvedValue({
60
- data: {
61
- wallet: {} // Missing address
62
- }
63
- })
156
+
157
+ // Always 500 errors
158
+ mockedAxios.post.mockRejectedValue(makeAxiosError('server error', 500))
64
159
 
65
160
  const result = await pregenerateInAppWallet(
66
161
  mockProjectId,
@@ -69,12 +164,19 @@ describe('pregenerateInAppWallet', () => {
69
164
  mockEmail
70
165
  )
71
166
 
167
+ // retry attempts are implementation-dependent (your retry opts). We assert >1 calls
168
+ expect(mockedAxios.post.mock.calls.length).toBeGreaterThanOrEqual(1)
169
+ // final result should be null when retries exhausted
72
170
  expect(result).toBeNull()
171
+ // error should have been logged
172
+ expect((console.error as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(1)
73
173
  })
74
174
 
75
- it('returns null on axios error', async () => {
76
- mockedGetSecret.mockResolvedValue(mockSecretValue)
77
- mockedAxios.post.mockRejectedValue(new Error('Network error'))
175
+ it('logs invalid wallet shape and returns null when wallet.address is missing (2xx response)', async () => {
176
+ mockedGetSecret.mockResolvedValueOnce(mockSecretValue)
177
+
178
+ // Simulate a 200 response that has wallet but no address
179
+ mockedAxios.post.mockResolvedValueOnce(makeSuccessResponse({ wallet: {} }))
78
180
 
79
181
  const result = await pregenerateInAppWallet(
80
182
  mockProjectId,
@@ -83,6 +185,22 @@ describe('pregenerateInAppWallet', () => {
83
185
  mockEmail
84
186
  )
85
187
 
188
+ // axios was called once, but the function returns null (final failure)
189
+ expect(mockedAxios.post).toHaveBeenCalledTimes(1)
86
190
  expect(result).toBeNull()
191
+
192
+ // console.error should be called with the shape error message
193
+ expect((console.error as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(1)
194
+
195
+ // the first arg of the logged call should be the string we log in the implementation
196
+ // (adjust if your message differs)
197
+ const firstArg = (console.error as jest.Mock).mock.calls[0][0]
198
+ expect(firstArg).toEqual(
199
+ 'Invalid wallet response shape' /* or 'Invalid wallet response shape' */
200
+ )
201
+
202
+ // the second arg should contain status and data info
203
+ const secondArg = (console.error as jest.Mock).mock.calls[0][1]
204
+ expect(secondArg).toEqual(expect.objectContaining({ status: 200, data: { wallet: {} } }))
87
205
  })
88
206
  })