@ioka-technologies/asyncapi-ts-client-template 0.0.7 → 0.0.10
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 +468 -122
- package/examples/auth-retry/README.md +384 -0
- package/examples/auth-retry/asyncapi.yaml +464 -0
- package/examples/auth-retry/login-flow-example.ts +375 -0
- package/package.json +3 -2
- package/template/helpers/security.js +137 -0
- package/template/src/client.ts.js +24 -2
- package/template/src/models.ts.js +157 -17
- package/template/src/runtime/auth/headers.ts.js +95 -0
- package/template/src/runtime/auth/index.ts.js +15 -0
- package/template/src/runtime/auth/types.ts.js +74 -0
- package/template/src/runtime/retry/index.ts.js +18 -0
- package/template/src/runtime/retry/manager.ts.js +150 -0
- package/template/src/runtime/retry/presets.ts.js +81 -0
- package/template/src/runtime/retry/types.ts.js +60 -0
- package/template/src/runtime/transports/http.ts.js +112 -17
- package/template/src/runtime/transports/websocket.ts.js +28 -3
- package/template/src/runtime/types.ts.js +12 -3
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { File } from '@asyncapi/generator-react-sdk';
|
|
3
3
|
|
|
4
4
|
function generateModels(asyncapi) {
|
|
5
|
-
let content =
|
|
5
|
+
let content = '// Generated TypeScript models from AsyncAPI specification\n\n';
|
|
6
6
|
|
|
7
7
|
// Helper functions for TypeScript identifier generation
|
|
8
8
|
function toTypeScriptIdentifier(str) {
|
|
@@ -42,8 +42,104 @@ function generateModels(asyncapi) {
|
|
|
42
42
|
// Extract message schemas and build channel mapping
|
|
43
43
|
const components = asyncapi.components();
|
|
44
44
|
const messageSchemas = [];
|
|
45
|
+
const componentSchemas = [];
|
|
45
46
|
const messageToChannels = new Map();
|
|
46
47
|
const generatedTypes = new Set();
|
|
48
|
+
const schemaRegistry = new Map();
|
|
49
|
+
|
|
50
|
+
// Build schema registry from components.schemas
|
|
51
|
+
// Try to access the raw AsyncAPI document
|
|
52
|
+
let rawDoc = null;
|
|
53
|
+
try {
|
|
54
|
+
if (asyncapi.json && typeof asyncapi.json === 'function') {
|
|
55
|
+
rawDoc = asyncapi.json();
|
|
56
|
+
} else if (asyncapi._json) {
|
|
57
|
+
rawDoc = asyncapi._json;
|
|
58
|
+
}
|
|
59
|
+
} catch (e) {
|
|
60
|
+
// Ignore
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Extract schemas from raw document if available
|
|
64
|
+
if (rawDoc && rawDoc.components && rawDoc.components.schemas) {
|
|
65
|
+
Object.entries(rawDoc.components.schemas).forEach(([name, schema]) => {
|
|
66
|
+
if (name && typeof name === 'string' && schema && typeof schema === 'object') {
|
|
67
|
+
schemaRegistry.set(name, schema);
|
|
68
|
+
componentSchemas.push({
|
|
69
|
+
name,
|
|
70
|
+
typeName: toTypeScriptTypeName(name),
|
|
71
|
+
schema: schema,
|
|
72
|
+
description: schema.description
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Fallback: try the components.schemas() method
|
|
79
|
+
if (componentSchemas.length === 0 && components && components.schemas) {
|
|
80
|
+
try {
|
|
81
|
+
const schemas = components.schemas();
|
|
82
|
+
if (schemas) {
|
|
83
|
+
// Try different ways to access schemas
|
|
84
|
+
let schemaEntries = [];
|
|
85
|
+
|
|
86
|
+
if (schemas instanceof Map) {
|
|
87
|
+
schemaEntries = Array.from(schemas.entries());
|
|
88
|
+
} else if (typeof schemas === 'object') {
|
|
89
|
+
schemaEntries = Object.entries(schemas);
|
|
90
|
+
} else if (schemas.all && typeof schemas.all === 'function') {
|
|
91
|
+
// AsyncAPI parser might have an all() method
|
|
92
|
+
const allSchemas = schemas.all();
|
|
93
|
+
if (Array.isArray(allSchemas)) {
|
|
94
|
+
schemaEntries = allSchemas.map(schema => {
|
|
95
|
+
const name = schema.uid ? schema.uid() : (schema.id ? schema.id() : null);
|
|
96
|
+
return [name, schema];
|
|
97
|
+
}).filter(([name]) => name);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
schemaEntries.forEach(([name, schema]) => {
|
|
102
|
+
// Skip internal AsyncAPI parser objects and numeric keys
|
|
103
|
+
if (!name || name === 'collections' || name === '_meta' || name.startsWith('_') || /^\d+$/.test(name)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let schemaData = null;
|
|
108
|
+
let description = null;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
// Handle different schema object types
|
|
112
|
+
if (schema && typeof schema.json === 'function') {
|
|
113
|
+
schemaData = schema.json();
|
|
114
|
+
} else if (schema && typeof schema === 'object') {
|
|
115
|
+
schemaData = schema;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (schema && typeof schema.description === 'function') {
|
|
119
|
+
description = schema.description();
|
|
120
|
+
} else if (schema && schema.description) {
|
|
121
|
+
description = schema.description;
|
|
122
|
+
}
|
|
123
|
+
} catch (e) {
|
|
124
|
+
// Ignore schema extraction errors
|
|
125
|
+
console.warn(`Failed to extract schema for ${name}:`, e.message);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (schemaData && typeof name === 'string' && name.length > 0) {
|
|
129
|
+
schemaRegistry.set(name, schemaData);
|
|
130
|
+
componentSchemas.push({
|
|
131
|
+
name,
|
|
132
|
+
typeName: toTypeScriptTypeName(name),
|
|
133
|
+
schema: schemaData,
|
|
134
|
+
description
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
} catch (e) {
|
|
140
|
+
console.warn('Failed to extract component schemas:', e.message);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
47
143
|
|
|
48
144
|
// First, build channel to message mapping
|
|
49
145
|
if (asyncapi.channels) {
|
|
@@ -126,13 +222,25 @@ function generateModels(asyncapi) {
|
|
|
126
222
|
}
|
|
127
223
|
|
|
128
224
|
// Helper function to convert JSON schema to TypeScript type
|
|
129
|
-
function jsonSchemaToTypeScriptType(schema) {
|
|
225
|
+
function jsonSchemaToTypeScriptType(schema, fieldName = '') {
|
|
130
226
|
if (!schema) return 'any';
|
|
131
227
|
|
|
132
|
-
// Handle $ref
|
|
228
|
+
// Handle $ref - resolve from schema registry
|
|
133
229
|
if (schema.$ref) {
|
|
134
230
|
const refName = schema.$ref.split('/').pop();
|
|
135
|
-
return
|
|
231
|
+
// Always return the type name for $ref, since we generate all component schemas
|
|
232
|
+
const typeName = toTypeScriptTypeName(refName);
|
|
233
|
+
return typeName;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Handle resolved $ref - check for x-parser-schema-id which indicates original schema name
|
|
237
|
+
if (schema['x-parser-schema-id'] && typeof schema['x-parser-schema-id'] === 'string') {
|
|
238
|
+
const schemaId = schema['x-parser-schema-id'];
|
|
239
|
+
// Check if this matches a known component schema
|
|
240
|
+
if (schemaRegistry.has(schemaId)) {
|
|
241
|
+
const typeName = toTypeScriptTypeName(schemaId);
|
|
242
|
+
return typeName;
|
|
243
|
+
}
|
|
136
244
|
}
|
|
137
245
|
|
|
138
246
|
if (!schema.type) {
|
|
@@ -158,10 +266,19 @@ function generateModels(asyncapi) {
|
|
|
158
266
|
case 'boolean':
|
|
159
267
|
return 'boolean';
|
|
160
268
|
case 'array': {
|
|
161
|
-
|
|
162
|
-
|
|
269
|
+
if (schema.items) {
|
|
270
|
+
const itemType = jsonSchemaToTypeScriptType(schema.items, fieldName);
|
|
271
|
+
return `${itemType}[]`;
|
|
272
|
+
}
|
|
273
|
+
return 'any[]';
|
|
163
274
|
}
|
|
164
275
|
case 'object':
|
|
276
|
+
// For objects with properties, we should generate inline types or check if it's a known schema
|
|
277
|
+
if (schema.properties) {
|
|
278
|
+
// This is a complex object - for now return Record<string, any>
|
|
279
|
+
// In a more sophisticated implementation, we could generate inline types
|
|
280
|
+
return 'Record<string, any>';
|
|
281
|
+
}
|
|
165
282
|
return 'Record<string, any>';
|
|
166
283
|
default:
|
|
167
284
|
return 'any';
|
|
@@ -175,7 +292,7 @@ function generateModels(asyncapi) {
|
|
|
175
292
|
}
|
|
176
293
|
|
|
177
294
|
const fields = Object.entries(schema.properties).map(([fieldName, fieldSchema]) => {
|
|
178
|
-
const tsType = jsonSchemaToTypeScriptType(fieldSchema);
|
|
295
|
+
const tsType = jsonSchemaToTypeScriptType(fieldSchema, fieldName);
|
|
179
296
|
const optional = !schema.required || !schema.required.includes(fieldName);
|
|
180
297
|
const optionalMarker = optional ? '?' : '';
|
|
181
298
|
|
|
@@ -190,31 +307,54 @@ function generateModels(asyncapi) {
|
|
|
190
307
|
return fields;
|
|
191
308
|
}
|
|
192
309
|
|
|
193
|
-
// Generate interfaces for
|
|
310
|
+
// Generate interfaces for component schemas first (so they can be referenced by messages)
|
|
311
|
+
componentSchemas.forEach(schema => {
|
|
312
|
+
const doc = schema.description ? `/** ${schema.description} */\n` : `/** ${schema.name} */\n`;
|
|
313
|
+
|
|
314
|
+
content += `${doc}export interface ${schema.typeName} {\n`;
|
|
315
|
+
content += generateMessageInterface(schema.schema, schema.typeName);
|
|
316
|
+
content += '\n}\n\n';
|
|
317
|
+
|
|
318
|
+
// Track generated types to avoid duplicates
|
|
319
|
+
generatedTypes.add(schema.typeName);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// Generate interfaces for each message (only if not already generated as component schema)
|
|
194
323
|
messageSchemas.forEach(schema => {
|
|
324
|
+
const interfaceName = `${schema.typeName}Payload`;
|
|
325
|
+
|
|
326
|
+
// Check if this is a duplicate of a component schema
|
|
327
|
+
// For message payloads that match component schema names, skip the payload version
|
|
328
|
+
if (generatedTypes.has(schema.typeName) || generatedTypes.has(interfaceName)) {
|
|
329
|
+
// Skip generating the payload version if we already have the component schema
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
195
333
|
const doc = schema.description ? `/** ${schema.description} */\n` : `/** ${schema.name} message payload */\n`;
|
|
196
334
|
|
|
197
|
-
content += `${doc}export interface ${
|
|
335
|
+
content += `${doc}export interface ${interfaceName} {\n`;
|
|
198
336
|
content += generateMessageInterface(schema.payload, schema.typeName);
|
|
199
|
-
content +=
|
|
337
|
+
content += '\n}\n\n';
|
|
338
|
+
|
|
339
|
+
generatedTypes.add(interfaceName);
|
|
200
340
|
});
|
|
201
341
|
|
|
202
342
|
// Generate a union type for all message payloads
|
|
203
343
|
if (messageSchemas.length > 0) {
|
|
204
344
|
const payloadTypes = messageSchemas.map(schema => `${schema.typeName}Payload`).join(' | ');
|
|
205
|
-
content +=
|
|
345
|
+
content += '/** Union type for all message payloads */\n';
|
|
206
346
|
content += `export type MessagePayload = ${payloadTypes};\n\n`;
|
|
207
347
|
|
|
208
348
|
// Generate message type constants
|
|
209
|
-
content +=
|
|
210
|
-
content +=
|
|
349
|
+
content += '/** Message type constants */\n';
|
|
350
|
+
content += 'export const MessageTypes = {\n';
|
|
211
351
|
messageSchemas.forEach(schema => {
|
|
212
352
|
content += ` ${schema.typeName.toUpperCase()}: '${schema.name}',\n`;
|
|
213
353
|
});
|
|
214
|
-
content +=
|
|
354
|
+
content += '} as const;\n\n';
|
|
215
355
|
|
|
216
|
-
content +=
|
|
217
|
-
content +=
|
|
356
|
+
content += '/** Message type union */\n';
|
|
357
|
+
content += 'export type MessageType = typeof MessageTypes[keyof typeof MessageTypes];\n\n';
|
|
218
358
|
}
|
|
219
359
|
|
|
220
360
|
return content;
|
|
@@ -226,4 +366,4 @@ module.exports = function ({ asyncapi, params }) {
|
|
|
226
366
|
{generateModels(asyncapi)}
|
|
227
367
|
</File>
|
|
228
368
|
);
|
|
229
|
-
}
|
|
369
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/* eslint-disable no-unused-vars */
|
|
2
|
+
import { File } from '@asyncapi/generator-react-sdk';
|
|
3
|
+
|
|
4
|
+
module.exports = function ({ asyncapi, params }) {
|
|
5
|
+
return (
|
|
6
|
+
<File name="headers.ts">
|
|
7
|
+
{`import { AuthCredentials, AuthError } from './types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate authentication headers based on credentials
|
|
11
|
+
*/
|
|
12
|
+
export function generateAuthHeaders(auth: AuthCredentials): Record<string, string> {
|
|
13
|
+
const headers: Record<string, string> = {};
|
|
14
|
+
|
|
15
|
+
if (auth.jwt) {
|
|
16
|
+
headers['Authorization'] = \`Bearer \${auth.jwt}\`;
|
|
17
|
+
} else if (auth.basic) {
|
|
18
|
+
const credentials = btoa(\`\${auth.basic.username}:\${auth.basic.password}\`);
|
|
19
|
+
headers['Authorization'] = \`Basic \${credentials}\`;
|
|
20
|
+
} else if (auth.apikey) {
|
|
21
|
+
if (auth.apikey.location === 'header') {
|
|
22
|
+
headers[auth.apikey.name] = auth.apikey.key;
|
|
23
|
+
}
|
|
24
|
+
// Query parameters are handled in the transport layer
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return headers;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Generate query parameters for API key authentication
|
|
32
|
+
*/
|
|
33
|
+
export function generateAuthQueryParams(auth: AuthCredentials): Record<string, string> {
|
|
34
|
+
const params: Record<string, string> = {};
|
|
35
|
+
|
|
36
|
+
if (auth.apikey && auth.apikey.location === 'query') {
|
|
37
|
+
params[auth.apikey.name] = auth.apikey.key;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return params;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validate auth credentials
|
|
45
|
+
*/
|
|
46
|
+
export function validateAuthCredentials(auth: AuthCredentials): void {
|
|
47
|
+
if (auth.jwt) {
|
|
48
|
+
if (typeof auth.jwt !== 'string' || auth.jwt.trim() === '') {
|
|
49
|
+
throw new AuthError('JWT token must be a non-empty string', 'jwt');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (auth.basic) {
|
|
54
|
+
if (!auth.basic.username || !auth.basic.password) {
|
|
55
|
+
throw new AuthError('Basic auth requires both username and password', 'basic');
|
|
56
|
+
}
|
|
57
|
+
if (typeof auth.basic.username !== 'string' || typeof auth.basic.password !== 'string') {
|
|
58
|
+
throw new AuthError('Basic auth username and password must be strings', 'basic');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (auth.apikey) {
|
|
63
|
+
if (!auth.apikey.key || !auth.apikey.name) {
|
|
64
|
+
throw new AuthError('API key auth requires both key and name', 'apikey');
|
|
65
|
+
}
|
|
66
|
+
if (typeof auth.apikey.key !== 'string' || typeof auth.apikey.name !== 'string') {
|
|
67
|
+
throw new AuthError('API key and name must be strings', 'apikey');
|
|
68
|
+
}
|
|
69
|
+
if (!['header', 'query'].includes(auth.apikey.location)) {
|
|
70
|
+
throw new AuthError('API key location must be either "header" or "query"', 'apikey');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Check if auth credentials are provided
|
|
77
|
+
*/
|
|
78
|
+
export function hasAuthCredentials(auth?: AuthCredentials): boolean {
|
|
79
|
+
if (!auth) return false;
|
|
80
|
+
return !!(auth.jwt || auth.basic || auth.apikey);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get auth type from credentials
|
|
85
|
+
*/
|
|
86
|
+
export function getAuthType(auth: AuthCredentials): string | null {
|
|
87
|
+
if (auth.jwt) return 'jwt';
|
|
88
|
+
if (auth.basic) return 'basic';
|
|
89
|
+
if (auth.apikey) return 'apikey';
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
`}
|
|
93
|
+
</File>
|
|
94
|
+
);
|
|
95
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/* eslint-disable no-unused-vars */
|
|
2
|
+
import { File } from '@asyncapi/generator-react-sdk';
|
|
3
|
+
|
|
4
|
+
module.exports = function ({ asyncapi, params }) {
|
|
5
|
+
return (
|
|
6
|
+
<File name="index.ts">
|
|
7
|
+
{`// Auth types and interfaces
|
|
8
|
+
export * from './types';
|
|
9
|
+
|
|
10
|
+
// Auth header utilities
|
|
11
|
+
export * from './headers';
|
|
12
|
+
`}
|
|
13
|
+
</File>
|
|
14
|
+
);
|
|
15
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/* eslint-disable no-unused-vars */
|
|
2
|
+
import { File } from '@asyncapi/generator-react-sdk';
|
|
3
|
+
|
|
4
|
+
module.exports = function ({ asyncapi, params }) {
|
|
5
|
+
return (
|
|
6
|
+
<File name="types.ts">
|
|
7
|
+
{`/**
|
|
8
|
+
* Authentication credentials for different auth types
|
|
9
|
+
*/
|
|
10
|
+
export interface AuthCredentials {
|
|
11
|
+
/** JWT Bearer token */
|
|
12
|
+
jwt?: string;
|
|
13
|
+
|
|
14
|
+
/** Basic authentication credentials */
|
|
15
|
+
basic?: {
|
|
16
|
+
username: string;
|
|
17
|
+
password: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** API Key authentication */
|
|
21
|
+
apikey?: {
|
|
22
|
+
key: string;
|
|
23
|
+
location: 'header' | 'query';
|
|
24
|
+
name: string;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Auth-related error types
|
|
30
|
+
*/
|
|
31
|
+
export class AuthError extends Error {
|
|
32
|
+
constructor(message: string, public authType?: string) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'AuthError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class TokenExpiredError extends AuthError {
|
|
39
|
+
constructor(message: string = 'Token has expired') {
|
|
40
|
+
super(message, 'jwt');
|
|
41
|
+
this.name = 'TokenExpiredError';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class UnauthorizedError extends AuthError {
|
|
46
|
+
constructor(message: string = 'Unauthorized access') {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = 'UnauthorizedError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Auth event callbacks for monitoring and token refresh
|
|
54
|
+
*/
|
|
55
|
+
export interface AuthEventCallbacks {
|
|
56
|
+
/** Called when a 401 Unauthorized response is received */
|
|
57
|
+
onAuthError?: () => Promise<boolean>;
|
|
58
|
+
|
|
59
|
+
/** Called when token needs to be refreshed */
|
|
60
|
+
onTokenRefresh?: (oldToken: string) => Promise<string>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Security requirement analysis result
|
|
65
|
+
*/
|
|
66
|
+
export interface SecurityRequirement {
|
|
67
|
+
hasSecurityRequirements: boolean;
|
|
68
|
+
securitySchemes: any[];
|
|
69
|
+
requiresAuthentication: boolean;
|
|
70
|
+
}
|
|
71
|
+
`}
|
|
72
|
+
</File>
|
|
73
|
+
);
|
|
74
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/* eslint-disable no-unused-vars */
|
|
2
|
+
import { File } from '@asyncapi/generator-react-sdk';
|
|
3
|
+
|
|
4
|
+
module.exports = function ({ asyncapi, params }) {
|
|
5
|
+
return (
|
|
6
|
+
<File name="index.ts">
|
|
7
|
+
{`// Retry types and interfaces
|
|
8
|
+
export * from './types';
|
|
9
|
+
|
|
10
|
+
// Retry presets
|
|
11
|
+
export * from './presets';
|
|
12
|
+
|
|
13
|
+
// Retry manager
|
|
14
|
+
export * from './manager';
|
|
15
|
+
`}
|
|
16
|
+
</File>
|
|
17
|
+
);
|
|
18
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/* eslint-disable no-unused-vars */
|
|
2
|
+
import { File } from '@asyncapi/generator-react-sdk';
|
|
3
|
+
|
|
4
|
+
module.exports = function ({ asyncapi, params }) {
|
|
5
|
+
return (
|
|
6
|
+
<File name="manager.ts">
|
|
7
|
+
{`import { RetryConfig, RetryEventCallbacks, MaxRetriesExceededError } from './types';
|
|
8
|
+
import { getRetryConfig } from './presets';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Retry manager for handling exponential backoff and retry logic
|
|
12
|
+
*/
|
|
13
|
+
export class RetryManager {
|
|
14
|
+
private config: RetryConfig;
|
|
15
|
+
private callbacks?: RetryEventCallbacks;
|
|
16
|
+
|
|
17
|
+
constructor(config: RetryConfig, callbacks?: RetryEventCallbacks) {
|
|
18
|
+
this.config = config;
|
|
19
|
+
this.callbacks = callbacks;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Execute an operation with retry logic
|
|
24
|
+
*/
|
|
25
|
+
async executeWithRetry<T>(
|
|
26
|
+
operation: () => Promise<T>,
|
|
27
|
+
operationName: string
|
|
28
|
+
): Promise<T> {
|
|
29
|
+
if (!this.config.enabled) {
|
|
30
|
+
return operation();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let lastError: Error;
|
|
34
|
+
|
|
35
|
+
for (let attempt = 1; attempt <= this.config.maxAttempts; attempt++) {
|
|
36
|
+
try {
|
|
37
|
+
return await operation();
|
|
38
|
+
} catch (error) {
|
|
39
|
+
lastError = error as Error;
|
|
40
|
+
|
|
41
|
+
// Check if error is retryable
|
|
42
|
+
if (!this.isRetryable(error) || attempt === this.config.maxAttempts) {
|
|
43
|
+
if (attempt === this.config.maxAttempts && this.callbacks?.onRetryExhausted) {
|
|
44
|
+
this.callbacks.onRetryExhausted(operationName, lastError);
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Calculate delay with exponential backoff and jitter
|
|
50
|
+
const delay = this.calculateDelay(attempt);
|
|
51
|
+
|
|
52
|
+
// Call retry callback
|
|
53
|
+
if (this.callbacks?.onRetry) {
|
|
54
|
+
this.callbacks.onRetry(attempt, lastError, delay);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Wait before retrying
|
|
58
|
+
await this.sleep(delay);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// This should never be reached, but TypeScript requires it
|
|
63
|
+
throw new MaxRetriesExceededError(this.config.maxAttempts, lastError!);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check if an error is retryable based on configuration
|
|
68
|
+
*/
|
|
69
|
+
private isRetryable(error: any): boolean {
|
|
70
|
+
// Check HTTP status codes
|
|
71
|
+
if (error.status && this.config.retryableStatusCodes.includes(error.status)) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check error codes
|
|
76
|
+
if (error.code && this.config.retryableErrors.includes(error.code)) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check error names
|
|
81
|
+
if (error.name && this.config.retryableErrors.includes(error.name)) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Check error messages for common network errors
|
|
86
|
+
if (error.message) {
|
|
87
|
+
const message = error.message.toLowerCase();
|
|
88
|
+
for (const retryableError of this.config.retryableErrors) {
|
|
89
|
+
if (message.includes(retryableError.toLowerCase())) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Calculate delay with exponential backoff and optional jitter
|
|
100
|
+
*/
|
|
101
|
+
private calculateDelay(attempt: number): number {
|
|
102
|
+
let delay = this.config.baseDelay * Math.pow(this.config.backoffMultiplier, attempt - 1);
|
|
103
|
+
|
|
104
|
+
// Apply maximum delay cap
|
|
105
|
+
delay = Math.min(delay, this.config.maxDelay);
|
|
106
|
+
|
|
107
|
+
// Add jitter to prevent thundering herd
|
|
108
|
+
if (this.config.jitter) {
|
|
109
|
+
delay = delay * (0.5 + Math.random() * 0.5);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return Math.floor(delay);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Sleep for specified milliseconds
|
|
117
|
+
*/
|
|
118
|
+
private sleep(ms: number): Promise<void> {
|
|
119
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get current retry configuration
|
|
124
|
+
*/
|
|
125
|
+
getConfig(): RetryConfig {
|
|
126
|
+
return { ...this.config };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Update retry configuration
|
|
131
|
+
*/
|
|
132
|
+
updateConfig(config: Partial<RetryConfig>): void {
|
|
133
|
+
this.config = { ...this.config, ...config };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Create a retry manager from config or preset
|
|
139
|
+
*/
|
|
140
|
+
export function createRetryManager(
|
|
141
|
+
config: RetryConfig | string,
|
|
142
|
+
callbacks?: RetryEventCallbacks
|
|
143
|
+
): RetryManager {
|
|
144
|
+
const retryConfig = getRetryConfig(config as any);
|
|
145
|
+
return new RetryManager(retryConfig, callbacks);
|
|
146
|
+
}
|
|
147
|
+
`}
|
|
148
|
+
</File>
|
|
149
|
+
);
|
|
150
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/* eslint-disable no-unused-vars */
|
|
2
|
+
import { File } from '@asyncapi/generator-react-sdk';
|
|
3
|
+
|
|
4
|
+
module.exports = function ({ asyncapi, params }) {
|
|
5
|
+
return (
|
|
6
|
+
<File name="presets.ts">
|
|
7
|
+
{`import { RetryConfig, RetryPreset } from './types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Predefined retry configuration presets
|
|
11
|
+
*/
|
|
12
|
+
export const RETRY_PRESETS: Record<RetryPreset, RetryConfig> = {
|
|
13
|
+
none: {
|
|
14
|
+
enabled: false,
|
|
15
|
+
maxAttempts: 1,
|
|
16
|
+
baseDelay: 0,
|
|
17
|
+
maxDelay: 0,
|
|
18
|
+
backoffMultiplier: 1,
|
|
19
|
+
jitter: false,
|
|
20
|
+
retryableStatusCodes: [],
|
|
21
|
+
retryableErrors: []
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
conservative: {
|
|
25
|
+
enabled: true,
|
|
26
|
+
maxAttempts: 3,
|
|
27
|
+
baseDelay: 1000, // 1 second
|
|
28
|
+
maxDelay: 10000, // 10 seconds max
|
|
29
|
+
backoffMultiplier: 2,
|
|
30
|
+
jitter: true,
|
|
31
|
+
retryableStatusCodes: [500, 502, 503, 504],
|
|
32
|
+
retryableErrors: ['NETWORK_ERROR', 'TIMEOUT', 'ECONNRESET', 'ENOTFOUND']
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
balanced: {
|
|
36
|
+
enabled: true,
|
|
37
|
+
maxAttempts: 5,
|
|
38
|
+
baseDelay: 500, // 500ms
|
|
39
|
+
maxDelay: 30000, // 30 seconds max
|
|
40
|
+
backoffMultiplier: 2,
|
|
41
|
+
jitter: true,
|
|
42
|
+
retryableStatusCodes: [429, 500, 502, 503, 504],
|
|
43
|
+
retryableErrors: ['NETWORK_ERROR', 'TIMEOUT', 'CONNECTION_ERROR', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT']
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
aggressive: {
|
|
47
|
+
enabled: true,
|
|
48
|
+
maxAttempts: 10,
|
|
49
|
+
baseDelay: 100, // 100ms
|
|
50
|
+
maxDelay: 60000, // 1 minute max
|
|
51
|
+
backoffMultiplier: 1.5,
|
|
52
|
+
jitter: true,
|
|
53
|
+
retryableStatusCodes: [408, 429, 500, 502, 503, 504],
|
|
54
|
+
retryableErrors: ['NETWORK_ERROR', 'TIMEOUT', 'CONNECTION_ERROR', 'DNS_ERROR', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'ECONNREFUSED']
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get retry configuration from preset or return custom config
|
|
60
|
+
*/
|
|
61
|
+
export function getRetryConfig(config: RetryConfig | RetryPreset): RetryConfig {
|
|
62
|
+
if (typeof config === 'string') {
|
|
63
|
+
return RETRY_PRESETS[config];
|
|
64
|
+
}
|
|
65
|
+
return config;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Merge custom retry config with preset defaults
|
|
70
|
+
*/
|
|
71
|
+
export function mergeRetryConfig(preset: RetryPreset, overrides: Partial<RetryConfig>): RetryConfig {
|
|
72
|
+
const baseConfig = RETRY_PRESETS[preset];
|
|
73
|
+
return {
|
|
74
|
+
...baseConfig,
|
|
75
|
+
...overrides
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
`}
|
|
79
|
+
</File>
|
|
80
|
+
);
|
|
81
|
+
};
|