Skip to content

Commit 70c7411

Browse files
authored
Merge pull request #46 from ukwhatn/release/4.2.1
chore: bump version to 4.2.1
2 parents e12d1b1 + 26e7e7c commit 70c7411

File tree

8 files changed

+129
-9
lines changed

8 files changed

+129
-9
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ukwhatn/wikidot",
3-
"version": "4.2.0",
3+
"version": "4.2.1",
44
"description": "TypeScript library for interacting with Wikidot sites",
55
"type": "module",
66
"main": "./dist/index.cjs",

src/connector/amc-client.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
WikidotStatusError,
1111
} from '../common/errors';
1212
import { fromPromise, type WikidotResultAsync, wdErrAsync, wdOkAsync } from '../common/types';
13+
import { fetchWithRetry } from '../util/http';
1314
import {
1415
type AMCConfig,
1516
DEFAULT_AMC_CONFIG,
@@ -135,9 +136,10 @@ export class AMCClient {
135136

136137
return fromPromise(
137138
(async () => {
138-
const response = await fetch(`http://${siteName}.${this.domain}`, {
139+
const response = await fetchWithRetry(`http://${siteName}.${this.domain}`, this.config, {
139140
method: 'GET',
140141
redirect: 'manual',
142+
checkOk: false, // Don't retry on HTTP errors (301 is expected)
141143
});
142144

143145
// 404 means site does not exist

src/connector/auth.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { SessionCreateError } from '../common/errors';
22
import { fromPromise, type WikidotResultAsync, wdOkAsync } from '../common/types';
33
import type { AuthClientContext } from '../module/types';
4+
import { fetchWithRetry } from '../util/http';
5+
import { DEFAULT_AMC_CONFIG } from './amc-config';
46

57
const LOGIN_URL = 'https://www.wikidot.com/default--flow/login__LoginPopupScreen';
68

9+
/** Login retry limit (reduced to prevent account lockout) */
10+
const LOGIN_RETRY_LIMIT = 3;
11+
712
/**
813
* Login to Wikidot with username and password
914
* @param client - Client context (object with AMCClient)
@@ -25,10 +30,16 @@ export function login(
2530
event: 'login',
2631
});
2732

28-
const response = await fetch(LOGIN_URL, {
33+
// Use reduced retry limit for login to prevent account lockout
34+
const loginConfig = {
35+
...DEFAULT_AMC_CONFIG,
36+
retryLimit: LOGIN_RETRY_LIMIT,
37+
};
38+
const response = await fetchWithRetry(LOGIN_URL, loginConfig, {
2939
method: 'POST',
3040
headers: client.amcClient.header.getHeaders(),
3141
body: formData.toString(),
42+
checkOk: false, // Handle HTTP errors manually for better error messages
3243
});
3344

3445
// Check status code

src/module/page/page.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from '../../common/errors';
1515
import { fromPromise, type WikidotResultAsync } from '../../common/types';
1616
import type { AMCRequestBody } from '../../connector';
17+
import { fetchWithRetry } from '../../util/http';
1718
import { parseOdate, parseUser } from '../../util/parser';
1819
import type { Site } from '../site';
1920
import type { AbstractUser } from '../user';
@@ -784,13 +785,14 @@ export class PageCollection extends Array<Page> {
784785

785786
// Limit concurrent connections (using same semaphoreLimit as AMCClient)
786787
const limit = pLimit(site.client.amcClient.config.semaphoreLimit);
788+
const config = site.client.amcClient.config;
787789

788-
// Access with norender, noredirect
790+
// Access with norender, noredirect (with retry)
789791
const responses = await Promise.all(
790792
targetPages.map((page) =>
791793
limit(async () => {
792794
const url = `${page.getUrl()}/norender/true/noredirect/true`;
793-
const response = await fetch(url, {
795+
const response = await fetchWithRetry(url, config, {
794796
headers: site.client.amcClient.header.getHeaders(),
795797
});
796798
return { page, response };

src/module/site/site.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as cheerio from 'cheerio';
22
import { NoElementError, NotFoundException, UnexpectedError } from '../../common/errors';
33
import { fromPromise, type WikidotResultAsync } from '../../common/types';
44
import type { AMCRequestBody, AMCResponse } from '../../connector';
5+
import { fetchWithRetry } from '../../util/http';
56
import type { Client } from '../client/client';
67
import { ForumAccessor } from './accessors/forum-accessor';
78
import { MemberAccessor } from './accessors/member-accessor';
@@ -154,10 +155,11 @@ export class Site {
154155
static fromUnixName(client: Client, unixName: string): WikidotResultAsync<Site> {
155156
return fromPromise(
156157
(async () => {
157-
// Fetch site page (HTTP request, following redirects)
158+
// Fetch site page (HTTP request, following redirects, with retry)
158159
const url = `http://${unixName}.wikidot.com`;
159-
const response = await fetch(url, {
160+
const response = await fetchWithRetry(url, client.amcClient.config, {
160161
headers: client.amcClient.header.getHeaders(),
162+
checkOk: false, // Handle HTTP errors manually for better error messages
161163
});
162164

163165
if (!response.ok) {

src/module/user/user.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import pLimit from 'p-limit';
33
import { NoElementError, NotFoundException, UnexpectedError } from '../../common/errors';
44
import { fromPromise, type WikidotResultAsync } from '../../common/types';
55
import { DEFAULT_AMC_CONFIG } from '../../connector/amc-config';
6+
import { fetchWithRetry } from '../../util/http';
67
import { toUnix } from '../../util/string-util';
78
import type { ClientRef } from '../types';
89
import type { AbstractUser, UserType } from './abstract-user';
@@ -67,7 +68,9 @@ export class User implements AbstractUser {
6768
const unixName = toUnix(name);
6869
const url = `https://www.wikidot.com/user:info/${unixName}`;
6970

70-
const response = await fetch(url);
71+
const response = await fetchWithRetry(url, DEFAULT_AMC_CONFIG, {
72+
checkOk: false, // Handle HTTP errors manually
73+
});
7174
if (!response.ok) {
7275
throw new UnexpectedError(`Failed to fetch user info: ${response.status}`);
7376
}

src/util/http.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* HTTP utilities with retry mechanism
3+
*/
4+
import type { AMCConfig } from '../connector/amc-config';
5+
import { DEFAULT_AMC_CONFIG } from '../connector/amc-config';
6+
7+
/**
8+
* Calculate backoff time with exponential backoff and jitter
9+
* @param retryCount - Current retry count (1-based)
10+
* @param baseInterval - Base interval in milliseconds
11+
* @param backoffFactor - Exponential backoff factor
12+
* @param maxBackoff - Maximum backoff time in milliseconds
13+
* @returns Backoff time in milliseconds
14+
*/
15+
export function calculateBackoff(
16+
retryCount: number,
17+
baseInterval: number,
18+
backoffFactor: number,
19+
maxBackoff: number
20+
): number {
21+
const backoff = baseInterval * backoffFactor ** (retryCount - 1);
22+
const jitter = Math.random() * backoff * 0.1;
23+
return Math.min(backoff + jitter, maxBackoff);
24+
}
25+
26+
/**
27+
* Sleep for specified milliseconds
28+
*/
29+
function sleep(ms: number): Promise<void> {
30+
return new Promise((resolve) => setTimeout(resolve, ms));
31+
}
32+
33+
/**
34+
* Options for fetchWithRetry
35+
*/
36+
export interface FetchWithRetryOptions extends Omit<RequestInit, 'signal'> {
37+
/** Whether to check response.ok (default: true) */
38+
checkOk?: boolean;
39+
}
40+
41+
/**
42+
* Check if HTTP status code is retryable (5xx server errors)
43+
*/
44+
function isRetryableStatus(status: number): boolean {
45+
return status >= 500 && status < 600;
46+
}
47+
48+
/**
49+
* Fetch with automatic retry on timeout/network errors and 5xx errors
50+
* @param url - URL to fetch
51+
* @param config - AMC configuration (uses timeout, retryLimit, retryInterval, maxBackoff, backoffFactor)
52+
* @param options - Fetch options (RequestInit without signal)
53+
* @returns Response
54+
* @throws Error on all retries exhausted or non-retryable errors (4xx)
55+
*/
56+
export async function fetchWithRetry(
57+
url: string,
58+
config: AMCConfig = DEFAULT_AMC_CONFIG,
59+
options: FetchWithRetryOptions = {}
60+
): Promise<Response> {
61+
const { checkOk = true, ...fetchOptions } = options;
62+
63+
for (let attempt = 0; attempt < config.retryLimit; attempt++) {
64+
try {
65+
const response = await fetch(url, {
66+
...fetchOptions,
67+
signal: AbortSignal.timeout(config.timeout),
68+
});
69+
70+
// Don't retry 4xx errors - they are client errors that won't change on retry
71+
if (checkOk && !response.ok) {
72+
if (!isRetryableStatus(response.status)) {
73+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
74+
}
75+
// 5xx errors are retryable, continue to retry logic below
76+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
77+
}
78+
return response;
79+
} catch (error) {
80+
// Don't retry if it's a non-retryable HTTP error (4xx)
81+
if (error instanceof Error && error.message.startsWith('HTTP 4')) {
82+
throw error;
83+
}
84+
if (attempt >= config.retryLimit - 1) {
85+
throw error;
86+
}
87+
const backoff = calculateBackoff(
88+
attempt + 1,
89+
config.retryInterval,
90+
config.backoffFactor,
91+
config.maxBackoff
92+
);
93+
await sleep(backoff);
94+
}
95+
}
96+
throw new Error('Unreachable');
97+
}

src/util/quick-module.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import { z } from 'zod';
88
import { NotFoundException, UnexpectedError } from '../common/errors';
99
import { fromPromise, type WikidotResultAsync } from '../common/types';
10+
import { DEFAULT_AMC_CONFIG } from '../connector/amc-config';
11+
import { fetchWithRetry } from './http';
1012

1113
/**
1214
* QuickModule module name
@@ -66,11 +68,12 @@ async function requestQuickModule(
6668
): Promise<unknown> {
6769
const url = `https://www.wikidot.com/quickmodule.php?module=${moduleName}&s=${siteId}&q=${encodeURIComponent(query)}`;
6870

69-
const response = await fetch(url, {
71+
const response = await fetchWithRetry(url, DEFAULT_AMC_CONFIG, {
7072
method: 'GET',
7173
headers: {
7274
Accept: 'application/json',
7375
},
76+
checkOk: false, // Handle HTTP errors manually
7477
});
7578

7679
if (response.status === 500) {

0 commit comments

Comments
 (0)