fix(account): add retry and rate-limit backoff handling for API requests

This commit is contained in:
2026-03-28 10:42:47 +00:00
parent d27d5e3537
commit e73bc44277
4 changed files with 46 additions and 12 deletions

View File

@@ -1,5 +1,12 @@
# Changelog # Changelog
## 2026-03-28 - 1.1.1 - fix(account)
add retry and rate-limit backoff handling for API requests
- Enable retries and 429 backoff handling for JSON and text API requests.
- Add exponential backoff retries for binary downloads when rate limited.
- Introduce a small delay between paginated requests to reduce API throttling.
## 2026-03-28 - 1.1.0 - feat(build) ## 2026-03-28 - 1.1.0 - feat(build)
modernize the build configuration and add project documentation modernize the build configuration and add project documentation

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@apiclient.xyz/bookstack', name: '@apiclient.xyz/bookstack',
version: '1.1.0', version: '1.1.1',
description: 'A TypeScript API client for BookStack, providing easy access to books, chapters, pages, shelves, and more.' description: 'A TypeScript API client for BookStack, providing easy access to books, chapters, pages, shelves, and more.'
} }

View File

@@ -51,7 +51,9 @@ export class BookStackAccount {
let builder = plugins.smartrequest.SmartRequest.create() let builder = plugins.smartrequest.SmartRequest.create()
.url(url) .url(url)
.header('Authorization', `Token ${this.tokenId}:${this.tokenSecret}`) .header('Authorization', `Token ${this.tokenId}:${this.tokenSecret}`)
.header('Content-Type', 'application/json'); .header('Content-Type', 'application/json')
.retry(3)
.handle429Backoff({ maxRetries: 5, maxWaitTime: 30000, fallbackDelay: 2000 });
if (data) { if (data) {
builder = builder.json(data); builder = builder.json(data);
@@ -73,6 +75,10 @@ export class BookStackAccount {
break; break;
} }
if (response.status === 429) {
throw new Error(`Rate limited: ${method} ${path} — retries exhausted`);
}
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
throw new Error(`${method} ${path}: ${response.status} ${response.statusText} - ${errorText}`); throw new Error(`${method} ${path}: ${response.status} ${response.statusText} - ${errorText}`);
@@ -95,7 +101,9 @@ export class BookStackAccount {
let builder = plugins.smartrequest.SmartRequest.create() let builder = plugins.smartrequest.SmartRequest.create()
.url(url) .url(url)
.header('Authorization', `Token ${this.tokenId}:${this.tokenSecret}`) .header('Authorization', `Token ${this.tokenId}:${this.tokenSecret}`)
.header('Accept', 'text/plain'); .header('Accept', 'text/plain')
.retry(3)
.handle429Backoff({ maxRetries: 5, maxWaitTime: 30000, fallbackDelay: 2000 });
let response: Awaited<ReturnType<typeof builder.get>>; let response: Awaited<ReturnType<typeof builder.get>>;
switch (method) { switch (method) {
@@ -113,6 +121,10 @@ export class BookStackAccount {
break; break;
} }
if (response.status === 429) {
throw new Error(`Rate limited: ${method} ${path} — retries exhausted`);
}
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
throw new Error(`${method} ${path}: ${response.status} ${response.statusText} - ${errorText}`); throw new Error(`${method} ${path}: ${response.status} ${response.statusText} - ${errorText}`);
@@ -124,16 +136,28 @@ export class BookStackAccount {
/** @internal */ /** @internal */
async requestBinary(path: string): Promise<Uint8Array> { async requestBinary(path: string): Promise<Uint8Array> {
const url = `${this.baseUrl}/api${path}`; const url = `${this.baseUrl}/api${path}`;
const response = await fetch(url, { const maxRetries = 3;
headers: { for (let attempt = 0; attempt <= maxRetries; attempt++) {
Authorization: `Token ${this.tokenId}:${this.tokenSecret}`, const response = await fetch(url, {
}, headers: {
}); Authorization: `Token ${this.tokenId}:${this.tokenSecret}`,
if (!response.ok) { },
throw new Error(`GET ${path}: ${response.status} ${response.statusText}`); });
if (response.status === 429) {
if (attempt === maxRetries) {
throw new Error(`Rate limited: GET ${path} — retries exhausted`);
}
const delay = 2000 * Math.pow(2, attempt);
await new Promise((r) => setTimeout(r, delay));
continue;
}
if (!response.ok) {
throw new Error(`GET ${path}: ${response.status} ${response.statusText}`);
}
const buf = await response.arrayBuffer();
return new Uint8Array(buf);
} }
const buf = await response.arrayBuffer(); throw new Error(`GET ${path}: max retries exceeded`);
return new Uint8Array(buf);
} }
/** @internal — build a URL with list query params */ /** @internal — build a URL with list query params */

View File

@@ -20,6 +20,9 @@ export async function autoPaginate<T>(
const all: T[] = []; const all: T[] = [];
let offset = 0; let offset = 0;
while (true) { while (true) {
if (offset > 0) {
await new Promise((r) => setTimeout(r, 100));
}
const result = await fetchPage(offset, count); const result = await fetchPage(offset, count);
all.push(...result.data); all.push(...result.data);
offset += count; offset += count;