Skip to content

Commit 6eda8ea

Browse files
author
williamd5
authored
Merge pull request #33 from cloudnode-pro/23-smart-rate-limit-handling
Automatic handling for temporary API errors
2 parents 8901ba1 + 59ad4f9 commit 6eda8ea

File tree

7 files changed

+362
-61
lines changed

7 files changed

+362
-61
lines changed

browser/Cloudnode.js

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,37 @@ class Cloudnode {
1111
*/
1212
#token;
1313
/**
14-
* Base URL of the API
14+
* API client options
1515
* @readonly
1616
* @private
1717
*/
18-
#baseUrl;
18+
#options;
19+
/**
20+
* Default options
21+
* @readonly
22+
* @private
23+
* @static
24+
* @internal
25+
*/
26+
static #defaultOptions = {
27+
baseUrl: "https://api.cloudnode.pro/v5/",
28+
autoRetry: true,
29+
maxRetryDelay: 5,
30+
maxRetries: 3
31+
};
1932
/**
2033
* Construct a new Cloudnode API client
2134
* @param token API token to use for requests
22-
* @param [baseUrl="https://api.cloudnode.pro/v5/"] Base URL of the API
35+
* @param [options] Options for the API client
2336
*/
24-
constructor(token, baseUrl = "https://api.cloudnode.pro/v5/") {
37+
constructor(token, options = Cloudnode.#defaultOptions) {
38+
const fullOptions = Cloudnode.#defaultOptions;
39+
fullOptions.baseUrl = fullOptions.baseUrl ?? Cloudnode.#defaultOptions.baseUrl;
40+
fullOptions.autoRetry = fullOptions.autoRetry ?? Cloudnode.#defaultOptions.autoRetry;
41+
fullOptions.maxRetryDelay = fullOptions.maxRetryDelay ?? Cloudnode.#defaultOptions.maxRetryDelay;
42+
fullOptions.maxRetries = fullOptions.maxRetries ?? Cloudnode.#defaultOptions.maxRetries;
2543
this.#token = token;
26-
this.#baseUrl = baseUrl;
44+
this.#options = fullOptions;
2745
}
2846
/**
2947
* Send a request to the API
@@ -34,8 +52,8 @@ class Cloudnode {
3452
* @internal
3553
* @private
3654
*/
37-
async #sendRequest(operation, pathParams, queryParams, body) {
38-
const url = new URL(operation.path.replace(/^\/+/, ""), this.#baseUrl);
55+
async #sendRawRequest(operation, pathParams, queryParams, body) {
56+
const url = new URL(operation.path.replace(/^\/+/, ""), this.#options.baseUrl);
3957
for (const [key, value] of Object.entries(pathParams))
4058
url.pathname = url.pathname.replaceAll(`/:${key}`, `/${value}`);
4159
for (const [key, value] of Object.entries(queryParams))
@@ -79,6 +97,40 @@ class Cloudnode {
7997
else
8098
throw res;
8199
}
100+
/**
101+
* Send a request to the API with support for auto-retry
102+
* @param operation The operation to call
103+
* @param pathParams Path parameters to use in the request
104+
* @param queryParams Query parameters to use in the request
105+
* @param options API client options. Overrides the client's options
106+
* @internal
107+
* @private
108+
*/
109+
#sendRequest(operation, pathParams, queryParams, body, options) {
110+
return new Promise(async (resolve, reject) => {
111+
const send = (i = 0) => {
112+
this.#sendRawRequest(operation, pathParams, queryParams, body)
113+
.then(response => resolve(response))
114+
.catch(e => {
115+
options ??= this.#options;
116+
options.baseUrl ??= this.#options.baseUrl;
117+
options.autoRetry ??= this.#options.autoRetry;
118+
options.maxRetries ??= this.#options.maxRetries;
119+
options.maxRetryDelay ??= this.#options.maxRetryDelay;
120+
if (options.autoRetry && i < options.maxRetries && e instanceof Cloudnode.R.ApiResponse) {
121+
const res = e;
122+
const retryAfter = Number(res._response.status !== 429 ? res._response.headers["x-retry-after"] ?? res._response.headers["retry-after"] : res._response.headers["x-ratelimit-reset"] ?? res._response.headers["x-rate-limit-reset"] ?? res._response.headers["ratelimit-reset"] ?? res._response.headers["rate-limit-reset"] ?? res._response.headers["retry-after"] ?? res._response.headers["x-retry-after"]);
123+
if (Number.isNaN(retryAfter) || retryAfter > options.maxRetryDelay)
124+
return reject(e);
125+
setTimeout(send, Number(retryAfter) * 1000, ++i);
126+
}
127+
else
128+
reject(e);
129+
});
130+
};
131+
send(0);
132+
});
133+
}
82134
/**
83135
* Get another page of paginated results
84136
* @param response Response to get a different page of
@@ -318,7 +370,7 @@ class Cloudnode {
318370
*/
319371
request;
320372
constructor(response, request) {
321-
this.headers = Object.fromEntries(response.headers.entries());
373+
this.headers = Object.fromEntries([...response.headers.entries()].map(([k, v]) => [k.toLowerCase(), v]));
322374
this.ok = response.ok;
323375
this.redirected = response.redirected;
324376
this.status = response.status;
@@ -349,7 +401,7 @@ class Cloudnode {
349401
}
350402
}
351403
R.ApiResponse = ApiResponse;
352-
})(R || (R = {}));
404+
})(R = Cloudnode.R || (Cloudnode.R = {}));
353405
function makeApiResponse(data, response) {
354406
return Object.assign(new R.ApiResponse(response), data);
355407
}

browser/Cloudnode.min.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gen/docs.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,13 @@ export const globalTypes = {
2828
new DocSchema.Property("status", "number", "The status code of the response.", undefined, true),
2929
new DocSchema.Property("statusText", "string", "The status message corresponding to the status code. (e.g., `OK` for `200`).", undefined, true),
3030
new DocSchema.Property("url", "string", "The URL of the response.", undefined, true),
31-
])
31+
]),
32+
options: (config: Config) => new DocSchema.Group(config.name + ".Options", "Interface", "API client options", [
33+
new DocSchema.Property("baseUrl", "string", "The base URL of the API"),
34+
new DocSchema.Property("autoRetry", "boolean", "Whether to automatically retry requests that fail temporarily.\nIf enabled, when a request fails due to a temporary error, such as a rate limit, the request will be retried after the specified delay."),
35+
new DocSchema.Property("maxRetryDelay", "number", "The maximum number of seconds that is acceptable to wait before retrying a failed request.\nThis requires `autoRetry` to be enabled."),
36+
new DocSchema.Property("maxRetries", "number", "The maximum number of times to retry a failed request.\nThis requires `autoRetry` to be enabled.")
37+
]),
3238
} as const;
3339

3440
/**
@@ -47,6 +53,7 @@ export function generateDocSchema (schema: Schema, config: Config, pkg: Package)
4753
mainNamespace.properties.push(globalTypes.paginatedData(config));
4854
mainNamespace.properties.push(globalTypes.apiResponse(config));
4955
mainNamespace.properties.push(globalTypes.rawResponse(config));
56+
mainNamespace.properties.push(globalTypes.options(config));
5057
const operations: (Schema.Operation & {name: string})[] = [];
5158
for (const [name, operation] of Object.entries(schema.operations)) {
5259
if (operation.type === "operation") operations.push({name, ...operation});
@@ -69,7 +76,7 @@ export function generateDocSchema (schema: Schema, config: Config, pkg: Package)
6976
const always: DocSchema.Method[] = [];
7077
always.push(new DocSchema.Method(`new ${config.name}`, `Construct a new ${config.name} API client`, [
7178
new DocSchema.Parameter("token", "string", "API token to use for requests", false),
72-
new DocSchema.Parameter("baseUrl", "string", "Base URL of the API", false, config.baseUrl)
79+
new DocSchema.Parameter("options", `Partial<${config.name}.Options>`, "API client options", false, `{baseUrl: "${config.baseUrl}", autoRetry: true, maxRetryDelay: 5, maxRetries: 3}`)
7380
], undefined, []));
7481
always.push(new DocSchema.Method(`${config.name}.getPage<T>`, "Get another page of paginated results", [
7582
new DocSchema.Parameter("response", `${config.name}.ApiResponse<${config.name}.PaginatedData<T>>`, "Response to get a different page of", true),
@@ -134,6 +141,7 @@ export function linkType (type: string, config: Config, schema: Schema): string
134141
null: "https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/null",
135142
Date: "https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Date",
136143
Record: "https://www.typescriptlang.org/docs/handbook/utility-types.html#recordkeys-type",
144+
Partial: "https://www.typescriptlang.org/docs/handbook/utility-types.html#partialtype",
137145
Array: "https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array",
138146
Promise: "https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise",
139147
Error: "https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Error",

gen/templates/main.mustache

Lines changed: 99 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,42 @@ class {{config.name}} {
1313
*/
1414
readonly #token?: string;
1515
16+
1617
/**
17-
* Base URL of the API
18+
* API client options
1819
* @readonly
1920
* @private
2021
*/
21-
readonly #baseUrl: string;
22+
readonly #options: {{config.name}}.Options;
23+
24+
/**
25+
* Default options
26+
* @readonly
27+
* @private
28+
* @static
29+
* @internal
30+
*/
31+
static readonly #defaultOptions: {{config.name}}.Options = {
32+
baseUrl: "{{{config.baseUrl}}}",
33+
autoRetry: true,
34+
maxRetryDelay: 5,
35+
maxRetries: 3
36+
};
2237

2338
/**
2439
* Construct a new {{config.name}} API client
2540
* @param token API token to use for requests
26-
* @param [baseUrl="{{{config.baseUrl}}}"] Base URL of the API
41+
* @param [options] Options for the API client
2742
*/
28-
public constructor(token?: string, baseUrl = "{{{config.baseUrl}}}") {
43+
public constructor(token?: string, options: Partial<{{config.name}}.Options> = {{config.name}}.#defaultOptions) {
44+
const fullOptions = {{config.name}}.#defaultOptions;
45+
fullOptions.baseUrl = fullOptions.baseUrl ?? {{config.name}}.#defaultOptions.baseUrl;
46+
fullOptions.autoRetry = fullOptions.autoRetry ?? {{config.name}}.#defaultOptions.autoRetry;
47+
fullOptions.maxRetryDelay = fullOptions.maxRetryDelay ?? {{config.name}}.#defaultOptions.maxRetryDelay;
48+
fullOptions.maxRetries = fullOptions.maxRetries ?? {{config.name}}.#defaultOptions.maxRetries;
49+
2950
this.#token = token;
30-
this.#baseUrl = baseUrl;
51+
this.#options = fullOptions;
3152
}
3253

3354
/**
@@ -39,8 +60,8 @@ class {{config.name}} {
3960
* @internal
4061
* @private
4162
*/
42-
async #sendRequest<T>(operation: Schema.Operation, pathParams: Record<string, string>, queryParams: Record<string, string>, body?: any): Promise<Cloudnode.ApiResponse<T>> {
43-
const url = new URL(operation.path.replace(/^\/+/, ""), this.#baseUrl);
63+
async #sendRawRequest<T>(operation: Schema.Operation, pathParams: Record<string, string>, queryParams: Record<string, string>, body?: any): Promise<{{config.name}}.ApiResponse<T>> {
64+
const url = new URL(operation.path.replace(/^\/+/, ""), this.#options.baseUrl);
4465
for (const [key, value] of Object.entries(pathParams))
4566
url.pathname = url.pathname.replaceAll(`/:${key}`, `/${value}`);
4667
for (const [key, value] of Object.entries(queryParams))
@@ -76,19 +97,53 @@ class {{config.name}} {
7697
});
7798
}
7899
else data = text as any;
79-
const res = Cloudnode.makeApiResponse(data, new Cloudnode.RawResponse(response, {operation, pathParams, queryParams, body} as const));
100+
const res = {{config.name}}.makeApiResponse(data, new {{config.name}}.RawResponse(response, {operation, pathParams, queryParams, body} as const));
80101
if (response.ok) return res;
81102
else throw res;
82103
}
83104

105+
/**
106+
* Send a request to the API with support for auto-retry
107+
* @param operation The operation to call
108+
* @param pathParams Path parameters to use in the request
109+
* @param queryParams Query parameters to use in the request
110+
* @param options API client options. Overrides the client's options
111+
* @internal
112+
* @private
113+
*/
114+
#sendRequest<T>(operation: Schema.Operation, pathParams: Record<string, string>, queryParams: Record<string, string>, body?: any, options?: {{config.name}}.Options): Promise<{{config.name}}.ApiResponse<T>> {
115+
return new Promise(async (resolve, reject) => {
116+
const send = (i: number = 0) => {
117+
this.#sendRawRequest<T>(operation, pathParams, queryParams, body)
118+
.then(response => resolve(response))
119+
.catch(e => {
120+
options ??= this.#options;
121+
options.baseUrl ??= this.#options.baseUrl;
122+
options.autoRetry ??= this.#options.autoRetry;
123+
options.maxRetries ??= this.#options.maxRetries;
124+
options.maxRetryDelay ??= this.#options.maxRetryDelay;
125+
126+
if (options.autoRetry && i < options.maxRetries && e instanceof {{config.name}}.R.ApiResponse) {
127+
const res: {{config.name}}.R.ApiResponse = e;
128+
const retryAfter: number = Number(res._response.status !== 429 ? res._response.headers["x-retry-after"] ?? res._response.headers["retry-after"] : res._response.headers["x-ratelimit-reset"] ?? res._response.headers["x-rate-limit-reset"] ?? res._response.headers["ratelimit-reset"] ?? res._response.headers["rate-limit-reset"] ?? res._response.headers["retry-after"] ?? res._response.headers["x-retry-after"]);
129+
if (Number.isNaN(retryAfter) || retryAfter > options.maxRetryDelay) return reject(e);
130+
setTimeout(send, Number(retryAfter) * 1000, ++i);
131+
}
132+
else reject(e);
133+
});
134+
}
135+
send(0);
136+
});
137+
}
138+
84139
/**
85140
* Get another page of paginated results
86141
* @param response Response to get a different page of
87142
* @param page Page to get
88143
* @returns The new page or null if the page is out of bounds
89144
* @throws {Cloudnode.Error} Error returned by the API
90145
*/
91-
async getPage<T>(response: Cloudnode.ApiResponse<Cloudnode.PaginatedData<T>>, page: number): Promise<Cloudnode.ApiResponse<Cloudnode.PaginatedData<T>> | null> {
146+
async getPage<T>(response: {{config.name}}.ApiResponse<{{config.name}}.PaginatedData<T>>, page: number): Promise<{{config.name}}.ApiResponse<{{config.name}}.PaginatedData<T>> | null> {
92147
if (page * response.limit > response.total || page < 1) return null;
93148
const query = Object.assign({}, response._response.request.queryParams);
94149
query.page = page.toString();
@@ -101,7 +156,7 @@ class {{config.name}} {
101156
* @returns The next page or null if this is the last page
102157
* @throws {Cloudnode.Error} Error returned by the API
103158
*/
104-
async getNextPage<T>(response: Cloudnode.ApiResponse<Cloudnode.PaginatedData<T>>): Promise<Cloudnode.ApiResponse<Cloudnode.PaginatedData<T>> | null> {
159+
async getNextPage<T>(response: {{config.name}}.ApiResponse<{{config.name}}.PaginatedData<T>>): Promise<{{config.name}}.ApiResponse<{{config.name}}.PaginatedData<T>> | null> {
105160
return await this.getPage(response, response.page + 1);
106161
}
107162

@@ -111,7 +166,7 @@ class {{config.name}} {
111166
* @returns The previous page or null if this is the first page
112167
* @throws {Cloudnode.Error} Error returned by the API
113168
*/
114-
async getPreviousPage<T>(response: Cloudnode.ApiResponse<Cloudnode.PaginatedData<T>>): Promise<Cloudnode.ApiResponse<Cloudnode.PaginatedData<T>> | null> {
169+
async getPreviousPage<T>(response: {{config.name}}.ApiResponse<{{config.name}}.PaginatedData<T>>): Promise<{{config.name}}.ApiResponse<{{config.name}}.PaginatedData<T>> | null> {
115170
return await this.getPage(response, response.page - 1);
116171
}
117172

@@ -122,13 +177,13 @@ class {{config.name}} {
122177
* @returns All of the data in 1 page
123178
* @throws {Cloudnode.Error} Error returned by the API
124179
*/
125-
async getAllPages<T>(response: Cloudnode.ApiResponse<Cloudnode.PaginatedData<T>>): Promise<Cloudnode.PaginatedData<T>> {
180+
async getAllPages<T>(response: {{config.name}}.ApiResponse<{{config.name}}.PaginatedData<T>>): Promise<{{config.name}}.PaginatedData<T>> {
126181
const pages: (true | null)[] = new Array(Math.ceil(response.total / response.limit)).fill(null);
127182
pages[response.page - 1] = true;
128-
const promises: (Promise<Cloudnode.ApiResponse<Cloudnode.PaginatedData<T>> | null> | true)[] = pages.map((page, i) => page === null ? this.getPage(response, i + 1) : true);
183+
const promises: (Promise<{{config.name}}.ApiResponse<{{config.name}}.PaginatedData<T>> | null> | true)[] = pages.map((page, i) => page === null ? this.getPage(response, i + 1) : true);
129184
const newPages = await Promise.all(promises.filter(page => page !== true));
130185
newPages.splice(response.page - 1, 0, response);
131-
const allPages = newPages.filter(p => p !== null) as Cloudnode.ApiResponse<Cloudnode.PaginatedData<T>>[];
186+
const allPages = newPages.filter(p => p !== null) as {{config.name}}.ApiResponse<{{config.name}}.PaginatedData<T>>[];
132187
return {
133188
items: allPages.map(p => p.items).flat(),
134189
total: response.total,
@@ -261,7 +316,7 @@ namespace {{config.name}} {
261316
};
262317

263318
public constructor(response: import("node-fetch").Response, request: {operation: Schema.Operation, pathParams: Record<string, string>, queryParams: Record<string, string>, body: any}) {
264-
this.headers = Object.fromEntries(response.headers.entries());
319+
this.headers = Object.fromEntries([...response.headers.entries()].map(([k, v]) => [k.toLowerCase(), v]));
265320
this.ok = response.ok;
266321
this.redirected = response.redirected;
267322
this.status = response.status;
@@ -271,7 +326,7 @@ namespace {{config.name}} {
271326
}
272327
}
273328

274-
namespace R {
329+
export namespace R {
275330
export class ApiResponse {
276331
/**
277332
* API response
@@ -299,6 +354,34 @@ namespace {{config.name}} {
299354
export function makeApiResponse<T>(data: T, response: RawResponse): ApiResponse<T> {
300355
return Object.assign(new R.ApiResponse(response), data);
301356
}
357+
358+
/**
359+
* API client options
360+
*/
361+
export interface Options {
362+
/**
363+
* The base URL of the API
364+
*/
365+
baseUrl: string;
366+
367+
/**
368+
* Whether to automatically retry requests that fail temporarily.
369+
* If enabled, when a request fails due to a temporary error, such as a rate limit, the request will be retried after the specified delay.
370+
*/
371+
autoRetry: boolean;
372+
373+
/**
374+
* The maximum number of seconds that is acceptable to wait before retrying a failed request.
375+
* This requires {@link Options.autoRetry} to be enabled.
376+
*/
377+
maxRetryDelay: number;
378+
379+
/**
380+
* The maximum number of times to retry a failed request.
381+
* This requires {@link Options.autoRetry} to be enabled.
382+
*/
383+
maxRetries: number;
384+
}
302385
}
303386

304387
export default {{config.name}};

0 commit comments

Comments
 (0)