@foru-ms/sdk 1.1.1 → 1.2.1
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/README.md +293 -11
- package/dist/Client.d.ts +63 -4
- package/dist/Client.js +124 -22
- package/dist/errors.d.ts +52 -0
- package/dist/errors.js +95 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/resources/Integrations.d.ts +7 -0
- package/dist/resources/Integrations.js +6 -0
- package/dist/resources/Posts.d.ts +12 -0
- package/dist/resources/Posts.js +44 -0
- package/dist/resources/PrivateMessages.d.ts +4 -0
- package/dist/resources/PrivateMessages.js +6 -0
- package/dist/resources/SSO.d.ts +10 -0
- package/dist/resources/SSO.js +11 -0
- package/dist/resources/Tags.d.ts +8 -0
- package/dist/resources/Tags.js +24 -0
- package/dist/resources/Threads.d.ts +20 -0
- package/dist/resources/Threads.js +85 -0
- package/dist/resources/Users.d.ts +10 -0
- package/dist/resources/Users.js +26 -0
- package/dist/resources/Webhooks.d.ts +69 -0
- package/dist/resources/Webhooks.js +115 -0
- package/dist/response-types.d.ts +105 -0
- package/dist/response-types.js +2 -0
- package/dist/utils.d.ts +80 -0
- package/dist/utils.js +138 -0
- package/examples/README.md +38 -0
- package/examples/authentication.ts +79 -0
- package/examples/error-handling.ts +133 -0
- package/examples/managing-threads.ts +130 -0
- package/examples/pagination.ts +81 -0
- package/examples/webhooks.ts +176 -0
- package/package.json +1 -1
- package/src/Client.ts +165 -25
- package/src/errors.ts +95 -0
- package/src/index.ts +3 -0
- package/src/resources/Integrations.ts +11 -0
- package/src/resources/Posts.ts +56 -0
- package/src/resources/PrivateMessages.ts +10 -0
- package/src/resources/SSO.ts +17 -0
- package/src/resources/Tags.ts +32 -0
- package/src/resources/Threads.ts +109 -0
- package/src/resources/Users.ts +36 -0
- package/src/resources/Webhooks.ts +131 -0
- package/src/response-types.ts +113 -0
- package/src/utils.ts +182 -0
package/src/resources/Users.ts
CHANGED
|
@@ -75,6 +75,42 @@ export class UsersResource {
|
|
|
75
75
|
});
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
async getThreads(id: string, params?: {
|
|
79
|
+
query?: string;
|
|
80
|
+
cursor?: string;
|
|
81
|
+
filter?: 'newest' | 'oldest';
|
|
82
|
+
}): Promise<import('../types').ThreadListResponse> {
|
|
83
|
+
const searchParams = new URLSearchParams();
|
|
84
|
+
if (params) {
|
|
85
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
86
|
+
if (value !== undefined) {
|
|
87
|
+
searchParams.append(key, value as string);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return this.client.request<import('../types').ThreadListResponse>(`/user/${id}/threads?${searchParams.toString()}`, {
|
|
92
|
+
method: 'GET',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async getPosts(id: string, params?: {
|
|
97
|
+
query?: string;
|
|
98
|
+
cursor?: string;
|
|
99
|
+
filter?: 'newest' | 'oldest';
|
|
100
|
+
}): Promise<import('../types').PostListResponse> {
|
|
101
|
+
const searchParams = new URLSearchParams();
|
|
102
|
+
if (params) {
|
|
103
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
104
|
+
if (value !== undefined) {
|
|
105
|
+
searchParams.append(key, value as string);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return this.client.request<import('../types').PostListResponse>(`/user/${id}/posts?${searchParams.toString()}`, {
|
|
110
|
+
method: 'GET',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
78
114
|
async getFollowers(id: string, params?: {
|
|
79
115
|
query?: string;
|
|
80
116
|
cursor?: string;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { ForumClient } from '../Client';
|
|
2
2
|
import { Webhook, WebhookListResponse } from '../types';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Resource for managing webhooks
|
|
6
|
+
*/
|
|
4
7
|
export class WebhooksResource {
|
|
5
8
|
private client: ForumClient;
|
|
6
9
|
|
|
@@ -8,12 +11,21 @@ export class WebhooksResource {
|
|
|
8
11
|
this.client = client;
|
|
9
12
|
}
|
|
10
13
|
|
|
14
|
+
/**
|
|
15
|
+
* List all webhooks
|
|
16
|
+
* @returns Promise resolving to list of webhooks
|
|
17
|
+
*/
|
|
11
18
|
async list(): Promise<WebhookListResponse> {
|
|
12
19
|
return this.client.request<WebhookListResponse>('/webhooks', {
|
|
13
20
|
method: 'GET',
|
|
14
21
|
});
|
|
15
22
|
}
|
|
16
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Create a new webhook
|
|
26
|
+
* @param payload - Webhook configuration
|
|
27
|
+
* @returns Promise resolving to created webhook
|
|
28
|
+
*/
|
|
17
29
|
async create(payload: {
|
|
18
30
|
name: string;
|
|
19
31
|
url: string;
|
|
@@ -25,12 +37,23 @@ export class WebhooksResource {
|
|
|
25
37
|
});
|
|
26
38
|
}
|
|
27
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Get a webhook by ID
|
|
42
|
+
* @param id - Webhook ID
|
|
43
|
+
* @returns Promise resolving to webhook details
|
|
44
|
+
*/
|
|
28
45
|
async retrieve(id: string): Promise<{ webhook: Webhook }> {
|
|
29
46
|
return this.client.request<{ webhook: Webhook }>(`/webhooks/${id}`, {
|
|
30
47
|
method: 'GET',
|
|
31
48
|
});
|
|
32
49
|
}
|
|
33
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Update a webhook
|
|
53
|
+
* @param id - Webhook ID
|
|
54
|
+
* @param payload - Updated webhook data
|
|
55
|
+
* @returns Promise resolving to updated webhook
|
|
56
|
+
*/
|
|
34
57
|
async update(id: string, payload: {
|
|
35
58
|
name?: string;
|
|
36
59
|
url?: string;
|
|
@@ -43,12 +66,23 @@ export class WebhooksResource {
|
|
|
43
66
|
});
|
|
44
67
|
}
|
|
45
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Delete a webhook
|
|
71
|
+
* @param id - Webhook ID
|
|
72
|
+
* @returns Promise resolving to deletion confirmation
|
|
73
|
+
*/
|
|
46
74
|
async delete(id: string): Promise<{ success: boolean }> {
|
|
47
75
|
return this.client.request<{ success: boolean }>(`/webhooks/${id}`, {
|
|
48
76
|
method: 'DELETE',
|
|
49
77
|
});
|
|
50
78
|
}
|
|
51
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Get webhook delivery history
|
|
82
|
+
* @param id - Webhook ID
|
|
83
|
+
* @param params - Query parameters
|
|
84
|
+
* @returns Promise resolving to delivery history
|
|
85
|
+
*/
|
|
52
86
|
async getDeliveries(id: string, params?: { cursor?: string }): Promise<{ deliveries: any[]; total: number; nextCursor?: string }> {
|
|
53
87
|
const searchParams = new URLSearchParams();
|
|
54
88
|
if (params?.cursor) searchParams.append('cursor', params.cursor);
|
|
@@ -57,4 +91,101 @@ export class WebhooksResource {
|
|
|
57
91
|
method: 'GET',
|
|
58
92
|
});
|
|
59
93
|
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Verify webhook signature for security
|
|
97
|
+
* @param payload - Request body (should be the original object, not stringified)
|
|
98
|
+
* @param signature - Signature from X-Webhook-Signature header
|
|
99
|
+
* @param timestamp - Timestamp from X-Webhook-Timestamp header (in milliseconds)
|
|
100
|
+
* @param secret - Webhook secret from creation
|
|
101
|
+
* @param maxAge - Maximum age of webhook in milliseconds (default: 5 minutes)
|
|
102
|
+
* @returns True if signature is valid and timestamp is recent
|
|
103
|
+
* @example
|
|
104
|
+
* ```typescript
|
|
105
|
+
* // In your webhook handler
|
|
106
|
+
* app.post('/webhook', (req, res) => {
|
|
107
|
+
* const signature = req.headers['x-webhook-signature'];
|
|
108
|
+
* const timestamp = req.headers['x-webhook-timestamp'];
|
|
109
|
+
* const event = req.headers['x-webhook-event'];
|
|
110
|
+
*
|
|
111
|
+
* // Verify signature and timestamp
|
|
112
|
+
* const isValid = client.webhooks.verifySignature(
|
|
113
|
+
* req.body,
|
|
114
|
+
* signature,
|
|
115
|
+
* timestamp,
|
|
116
|
+
* 'your_webhook_secret'
|
|
117
|
+
* );
|
|
118
|
+
*
|
|
119
|
+
* if (!isValid) {
|
|
120
|
+
* return res.status(401).send('Invalid signature');
|
|
121
|
+
* }
|
|
122
|
+
*
|
|
123
|
+
* // Process webhook...
|
|
124
|
+
* console.log('Event:', event);
|
|
125
|
+
* res.sendStatus(200);
|
|
126
|
+
* });
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
verifySignature(
|
|
130
|
+
payload: any,
|
|
131
|
+
signature: string,
|
|
132
|
+
timestamp: string,
|
|
133
|
+
secret: string,
|
|
134
|
+
maxAge: number = 5 * 60 * 1000 // 5 minutes default
|
|
135
|
+
): boolean {
|
|
136
|
+
try {
|
|
137
|
+
// Verify timestamp (prevent replay attacks)
|
|
138
|
+
const webhookTimestamp = parseInt(timestamp, 10);
|
|
139
|
+
if (isNaN(webhookTimestamp)) {
|
|
140
|
+
console.error('Invalid webhook timestamp format');
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const age = Date.now() - webhookTimestamp;
|
|
145
|
+
if (age > maxAge) {
|
|
146
|
+
console.error('Webhook timestamp too old:', age, 'ms');
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Use Node.js crypto if available
|
|
151
|
+
if (typeof require !== 'undefined') {
|
|
152
|
+
try {
|
|
153
|
+
const crypto = require('crypto');
|
|
154
|
+
|
|
155
|
+
// Create the signed data: timestamp.payload
|
|
156
|
+
const data = `${timestamp}.${JSON.stringify(payload)}`;
|
|
157
|
+
|
|
158
|
+
// Calculate expected signature
|
|
159
|
+
const expectedSignature = crypto
|
|
160
|
+
.createHmac('sha256', secret)
|
|
161
|
+
.update(data)
|
|
162
|
+
.digest('hex');
|
|
163
|
+
|
|
164
|
+
// Use timing-safe comparison
|
|
165
|
+
if (crypto.timingSafeEqual) {
|
|
166
|
+
return crypto.timingSafeEqual(
|
|
167
|
+
Buffer.from(signature),
|
|
168
|
+
Buffer.from(expectedSignature)
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Fallback to simple comparison (less secure)
|
|
173
|
+
return signature === expectedSignature;
|
|
174
|
+
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.error('Error using Node.js crypto:', error);
|
|
177
|
+
// Fall through to browser implementation
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// For browser environments or when Node crypto is not available
|
|
182
|
+
console.warn('Webhook signature verification not fully supported in this environment');
|
|
183
|
+
console.warn('Please use Node.js for secure webhook verification');
|
|
184
|
+
return false;
|
|
185
|
+
|
|
186
|
+
} catch (error) {
|
|
187
|
+
console.error('Error verifying webhook signature:', error);
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
60
191
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { User } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rate limit information from API responses
|
|
5
|
+
*/
|
|
6
|
+
export interface RateLimitInfo {
|
|
7
|
+
/** Maximum number of requests allowed in the time window */
|
|
8
|
+
limit: number;
|
|
9
|
+
/** Number of requests remaining in the current window */
|
|
10
|
+
remaining: number;
|
|
11
|
+
/** Unix timestamp when the rate limit resets */
|
|
12
|
+
reset: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Metadata included with API responses
|
|
17
|
+
*/
|
|
18
|
+
export interface ResponseMetadata {
|
|
19
|
+
/** Rate limit information, if available */
|
|
20
|
+
rateLimit?: RateLimitInfo;
|
|
21
|
+
/** Request ID for debugging */
|
|
22
|
+
requestId?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Generic API response wrapper
|
|
27
|
+
*/
|
|
28
|
+
export interface APIResponse<T> {
|
|
29
|
+
/** Response data */
|
|
30
|
+
data: T;
|
|
31
|
+
/** Response metadata */
|
|
32
|
+
meta?: ResponseMetadata;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Response for interaction lists (likes, upvotes, etc.)
|
|
37
|
+
*/
|
|
38
|
+
export interface InteractionListResponse {
|
|
39
|
+
/** List of users who performed the interaction */
|
|
40
|
+
users: User[];
|
|
41
|
+
/** Cursor for next page */
|
|
42
|
+
nextCursor?: string;
|
|
43
|
+
/** Total count of interactions */
|
|
44
|
+
count: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Response for poll data
|
|
49
|
+
*/
|
|
50
|
+
export interface PollResponse {
|
|
51
|
+
/** Poll ID */
|
|
52
|
+
id: string;
|
|
53
|
+
/** Poll title */
|
|
54
|
+
title: string;
|
|
55
|
+
/** Poll options */
|
|
56
|
+
options: PollOption[];
|
|
57
|
+
/** Total number of votes */
|
|
58
|
+
totalVotes: number;
|
|
59
|
+
/** Whether the poll allows multiple votes */
|
|
60
|
+
allowMultiple?: boolean;
|
|
61
|
+
/** Poll expiration date */
|
|
62
|
+
expiresAt?: string;
|
|
63
|
+
/** User's vote, if userId was provided */
|
|
64
|
+
userVote?: {
|
|
65
|
+
optionId: string;
|
|
66
|
+
votedAt: string;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Poll option data
|
|
72
|
+
*/
|
|
73
|
+
export interface PollOption {
|
|
74
|
+
/** Option ID */
|
|
75
|
+
id: string;
|
|
76
|
+
/** Option title */
|
|
77
|
+
title: string;
|
|
78
|
+
/** Option color */
|
|
79
|
+
color?: string;
|
|
80
|
+
/** Number of votes for this option */
|
|
81
|
+
votes: number;
|
|
82
|
+
/** Percentage of total votes */
|
|
83
|
+
percentage: number;
|
|
84
|
+
/** Extended data */
|
|
85
|
+
extendedData?: Record<string, any>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Response for batch operations
|
|
90
|
+
*/
|
|
91
|
+
export interface BatchOperationResponse {
|
|
92
|
+
/** Number of items successfully processed */
|
|
93
|
+
success: number;
|
|
94
|
+
/** Number of items that failed */
|
|
95
|
+
failed: number;
|
|
96
|
+
/** Error details for failed items */
|
|
97
|
+
errors?: Array<{
|
|
98
|
+
id: string;
|
|
99
|
+
error: string;
|
|
100
|
+
}>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Pagination cursor response
|
|
105
|
+
*/
|
|
106
|
+
export interface CursorPagination<T> {
|
|
107
|
+
/** Array of items */
|
|
108
|
+
items: T[];
|
|
109
|
+
/** Cursor for next page */
|
|
110
|
+
nextCursor?: string;
|
|
111
|
+
/** Whether there are more items */
|
|
112
|
+
hasMore: boolean;
|
|
113
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { CursorPagination } from './response-types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pagination utilities for working with cursor-based pagination
|
|
5
|
+
*/
|
|
6
|
+
export class PaginationHelper {
|
|
7
|
+
/**
|
|
8
|
+
* Async generator to auto-paginate through all results
|
|
9
|
+
* @param fetchPage - Function to fetch a page of results
|
|
10
|
+
* @yields Individual items from each page
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* const helper = new PaginationHelper();
|
|
14
|
+
* for await (const thread of helper.paginateAll((cursor) =>
|
|
15
|
+
* client.threads.list({ cursor })
|
|
16
|
+
* )) {
|
|
17
|
+
* console.log(thread);
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
async *paginateAll<T>(
|
|
22
|
+
fetchPage: (cursor?: string) => Promise<{
|
|
23
|
+
threads?: T[];
|
|
24
|
+
posts?: T[];
|
|
25
|
+
users?: T[];
|
|
26
|
+
tags?: T[];
|
|
27
|
+
nextThreadCursor?: string;
|
|
28
|
+
nextPostCursor?: string;
|
|
29
|
+
nextUserCursor?: string;
|
|
30
|
+
nextTagCursor?: string;
|
|
31
|
+
}>
|
|
32
|
+
): AsyncIterableIterator<T> {
|
|
33
|
+
let cursor: string | undefined;
|
|
34
|
+
let hasMore = true;
|
|
35
|
+
|
|
36
|
+
while (hasMore) {
|
|
37
|
+
const response = await fetchPage(cursor);
|
|
38
|
+
|
|
39
|
+
// Determine which array and cursor to use
|
|
40
|
+
const items = response.threads || response.posts || response.users || response.tags || [];
|
|
41
|
+
const nextCursor = response.nextThreadCursor || response.nextPostCursor ||
|
|
42
|
+
response.nextUserCursor || response.nextTagCursor;
|
|
43
|
+
|
|
44
|
+
for (const item of items) {
|
|
45
|
+
yield item;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
cursor = nextCursor;
|
|
49
|
+
hasMore = !!nextCursor;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Fetch all items from a paginated endpoint
|
|
55
|
+
* @param fetchPage - Function to fetch a page of results
|
|
56
|
+
* @param maxPages - Maximum number of pages to fetch (default: Infinity)
|
|
57
|
+
* @returns Array of all items
|
|
58
|
+
*/
|
|
59
|
+
async fetchAllPages<T>(
|
|
60
|
+
fetchPage: (cursor?: string) => Promise<{
|
|
61
|
+
threads?: T[];
|
|
62
|
+
posts?: T[];
|
|
63
|
+
users?: T[];
|
|
64
|
+
tags?: T[];
|
|
65
|
+
nextThreadCursor?: string;
|
|
66
|
+
nextPostCursor?: string;
|
|
67
|
+
nextUserCursor?: string;
|
|
68
|
+
nextTagCursor?: string;
|
|
69
|
+
}>,
|
|
70
|
+
maxPages: number = Infinity
|
|
71
|
+
): Promise<T[]> {
|
|
72
|
+
const allItems: T[] = [];
|
|
73
|
+
let cursor: string | undefined;
|
|
74
|
+
let pageCount = 0;
|
|
75
|
+
|
|
76
|
+
while (pageCount < maxPages) {
|
|
77
|
+
const response = await fetchPage(cursor);
|
|
78
|
+
|
|
79
|
+
const items = response.threads || response.posts || response.users || response.tags || [];
|
|
80
|
+
const nextCursor = response.nextThreadCursor || response.nextPostCursor ||
|
|
81
|
+
response.nextUserCursor || response.nextTagCursor;
|
|
82
|
+
|
|
83
|
+
allItems.push(...items);
|
|
84
|
+
|
|
85
|
+
cursor = nextCursor;
|
|
86
|
+
pageCount++;
|
|
87
|
+
|
|
88
|
+
if (!nextCursor) break;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return allItems;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Input validation utilities
|
|
97
|
+
*/
|
|
98
|
+
export class Validator {
|
|
99
|
+
/**
|
|
100
|
+
* Validate that a string is not empty
|
|
101
|
+
*/
|
|
102
|
+
static isNonEmptyString(value: any, fieldName: string): void {
|
|
103
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
104
|
+
throw new Error(`${fieldName} must be a non-empty string`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Validate email format
|
|
110
|
+
*/
|
|
111
|
+
static isValidEmail(email: string): boolean {
|
|
112
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
113
|
+
return emailRegex.test(email);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Validate that a value is one of the allowed options
|
|
118
|
+
*/
|
|
119
|
+
static isOneOf<T>(value: T, options: T[], fieldName: string): void {
|
|
120
|
+
if (!options.includes(value)) {
|
|
121
|
+
throw new Error(`${fieldName} must be one of: ${options.join(', ')}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Validate cursor format (if needed)
|
|
127
|
+
*/
|
|
128
|
+
static isValidCursor(cursor: string): boolean {
|
|
129
|
+
return typeof cursor === 'string' && cursor.length > 0;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Retry utilities for handling transient failures
|
|
135
|
+
*/
|
|
136
|
+
export class RetryHelper {
|
|
137
|
+
/**
|
|
138
|
+
* Execute a function with exponential backoff retry
|
|
139
|
+
* @param fn - Function to execute
|
|
140
|
+
* @param maxRetries - Maximum number of retry attempts
|
|
141
|
+
* @param initialDelay - Initial delay in milliseconds
|
|
142
|
+
* @returns Result of the function
|
|
143
|
+
*/
|
|
144
|
+
static async withRetry<T>(
|
|
145
|
+
fn: () => Promise<T>,
|
|
146
|
+
maxRetries: number = 3,
|
|
147
|
+
initialDelay: number = 1000
|
|
148
|
+
): Promise<T> {
|
|
149
|
+
let lastError: Error;
|
|
150
|
+
|
|
151
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
152
|
+
try {
|
|
153
|
+
return await fn();
|
|
154
|
+
} catch (error: any) {
|
|
155
|
+
lastError = error;
|
|
156
|
+
|
|
157
|
+
// Don't retry on client errors (4xx) except 429
|
|
158
|
+
if (error.statusCode >= 400 && error.statusCode < 500 && error.statusCode !== 429) {
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Don't retry on last attempt
|
|
163
|
+
if (attempt === maxRetries) {
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Calculate delay with exponential backoff
|
|
168
|
+
const delay = error.retryAfter
|
|
169
|
+
? error.retryAfter * 1000
|
|
170
|
+
: initialDelay * Math.pow(2, attempt);
|
|
171
|
+
|
|
172
|
+
await this.sleep(delay);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
throw lastError!;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private static sleep(ms: number): Promise<void> {
|
|
180
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
181
|
+
}
|
|
182
|
+
}
|