mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-16 05:23:21 +08:00
fix: retry transient Weixin startup failures
This commit is contained in:
parent
6cb97c6a11
commit
c9ec84cc67
3 changed files with 167 additions and 97 deletions
186
lib/index.js
186
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,26 @@
|
||||||
import { WeixinApiError } from './weixin-api.mjs';
|
import { WeixinApiError } from './weixin-api.mjs';
|
||||||
import { createWeixinBridgeStatus, WeixinHarnessBridge } from './weixin-bridge.mjs';
|
import { createWeixinBridgeStatus, WeixinHarnessBridge } from './weixin-bridge.mjs';
|
||||||
|
|
||||||
|
const DEFAULT_START_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000]);
|
||||||
|
|
||||||
|
function startRetryDelays(value) {
|
||||||
|
if (value === undefined) return [...DEFAULT_START_RETRY_DELAYS_MS];
|
||||||
|
if (!Array.isArray(value)) throw new TypeError('startRetryDelaysMs must be an array');
|
||||||
|
return value.map((wait) => {
|
||||||
|
if (!Number.isFinite(wait) || wait < 0) {
|
||||||
|
throw new TypeError('startRetryDelaysMs must contain non-negative delays');
|
||||||
|
}
|
||||||
|
return wait;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryableStartError(error) {
|
||||||
|
if (!(error instanceof WeixinApiError)) return false;
|
||||||
|
if (error.code === 'network-error' || error.code === 'timeout') return true;
|
||||||
|
return error.code === 'http-error'
|
||||||
|
&& (error.status === 408 || error.status === 425 || error.status === 429 || error.status >= 500);
|
||||||
|
}
|
||||||
|
|
||||||
function delay(ms, signal) {
|
function delay(ms, signal) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
|
|
@ -42,6 +62,7 @@ export class WeixinRuntime {
|
||||||
#logger;
|
#logger;
|
||||||
#replyTimeoutMs;
|
#replyTimeoutMs;
|
||||||
#maxMessageChars;
|
#maxMessageChars;
|
||||||
|
#startRetryDelaysMs;
|
||||||
#status = createWeixinRuntimeStatus();
|
#status = createWeixinRuntimeStatus();
|
||||||
#bridge = null;
|
#bridge = null;
|
||||||
#abortController = null;
|
#abortController = null;
|
||||||
|
|
@ -57,6 +78,7 @@ export class WeixinRuntime {
|
||||||
logger = console,
|
logger = console,
|
||||||
replyTimeoutMs = 600_000,
|
replyTimeoutMs = 600_000,
|
||||||
maxMessageChars = 4_000,
|
maxMessageChars = 4_000,
|
||||||
|
startRetryDelaysMs,
|
||||||
}) {
|
}) {
|
||||||
if (!api || !config || !token || !harness || !state) {
|
if (!api || !config || !token || !harness || !state) {
|
||||||
throw new TypeError('WeixinRuntime requires API, account, token, Harness, and state');
|
throw new TypeError('WeixinRuntime requires API, account, token, Harness, and state');
|
||||||
|
|
@ -69,6 +91,7 @@ export class WeixinRuntime {
|
||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
this.#replyTimeoutMs = replyTimeoutMs;
|
this.#replyTimeoutMs = replyTimeoutMs;
|
||||||
this.#maxMessageChars = maxMessageChars;
|
this.#maxMessageChars = maxMessageChars;
|
||||||
|
this.#startRetryDelaysMs = startRetryDelays(startRetryDelaysMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
get status() {
|
get status() {
|
||||||
|
|
@ -92,10 +115,7 @@ export class WeixinRuntime {
|
||||||
try {
|
try {
|
||||||
await this.#harness.ensureRunning();
|
await this.#harness.ensureRunning();
|
||||||
this.#status.harnessReachable = true;
|
this.#status.harnessReachable = true;
|
||||||
await this.#api.notifyStart({
|
await this.#notifyStart();
|
||||||
baseUrl: this.#config.baseUrl,
|
|
||||||
token: this.#token,
|
|
||||||
});
|
|
||||||
this.#bridge = new WeixinHarnessBridge({
|
this.#bridge = new WeixinHarnessBridge({
|
||||||
api: this.#api,
|
api: this.#api,
|
||||||
baseUrl: this.#config.baseUrl,
|
baseUrl: this.#config.baseUrl,
|
||||||
|
|
@ -129,6 +149,25 @@ export class WeixinRuntime {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #notifyStart() {
|
||||||
|
for (let attempt = 0; ; attempt += 1) {
|
||||||
|
try {
|
||||||
|
return await this.#api.notifyStart({
|
||||||
|
baseUrl: this.#config.baseUrl,
|
||||||
|
token: this.#token,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const wait = this.#startRetryDelaysMs[attempt];
|
||||||
|
if (wait === undefined || !retryableStartError(error)) throw error;
|
||||||
|
this.#logger.warn?.(
|
||||||
|
`[dsh-weixin] account ${this.#config.botId} start request failed; retrying in ${wait}ms:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
await delay(wait);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async #runMonitor(signal) {
|
async #runMonitor(signal) {
|
||||||
let consecutiveFailures = 0;
|
let consecutiveFailures = 0;
|
||||||
while (!signal.aborted) {
|
while (!signal.aborted) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { WeixinApiError } from '../../../src/channels/weixin/weixin-api.mjs';
|
||||||
import { WeixinRuntime } from '../../../src/channels/weixin/weixin-runtime.mjs';
|
import { WeixinRuntime } from '../../../src/channels/weixin/weixin-runtime.mjs';
|
||||||
|
|
||||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
@ -89,3 +90,33 @@ test('runtime refuses to report ready when notifyStart rejects the stored token'
|
||||||
assert.equal(runtime.status.ready, false);
|
assert.equal(runtime.status.ready, false);
|
||||||
assert.equal(runtime.status.weixinConnectionState, 'failed');
|
assert.equal(runtime.status.weixinConnectionState, 'failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('runtime retries a transient notifyStart failure before reporting the account offline', async () => {
|
||||||
|
let startCalls = 0;
|
||||||
|
const runtime = new WeixinRuntime({
|
||||||
|
api: {
|
||||||
|
notifyStart: async () => {
|
||||||
|
startCalls += 1;
|
||||||
|
if (startCalls === 1) {
|
||||||
|
throw new WeixinApiError('network-error', 'temporary DNS failure');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
notifyStop: async () => {},
|
||||||
|
sendText: async () => {},
|
||||||
|
getUpdates: async ({ signal }) => new Promise((_resolve, reject) => {
|
||||||
|
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
config: { botId: 'wx_retry', baseUrl: 'https://ilinkai.weixin.qq.com/', ownerUserId: 'owner' },
|
||||||
|
token: 'bot-token',
|
||||||
|
harness: { ensureRunning: async () => true },
|
||||||
|
state: {},
|
||||||
|
startRetryDelaysMs: [0],
|
||||||
|
logger: { warn() {}, error() {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
const started = await runtime.start();
|
||||||
|
assert.equal(started.ready, true);
|
||||||
|
assert.equal(startCalls, 2);
|
||||||
|
await runtime.stop();
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue