diff --git a/docs/IMPLEMENTATION_HANDOFF.md b/docs/IMPLEMENTATION_HANDOFF.md index 818650a..5421622 100644 --- a/docs/IMPLEMENTATION_HANDOFF.md +++ b/docs/IMPLEMENTATION_HANDOFF.md @@ -24,6 +24,10 @@ `git diff --check` 均通过。RN Android 原生 Bridge 尚未基于本轮 Android `sdk-update` 制品执行宿主编译,因为本轮 Maven 快照尚未经 Jenkins 发布;不得据此 宣称原生集成已验证。 +- BugCollect 上传器已识别 HTTP 429 与 `BUGCOLLECT_RATE_LIMITED`,按 + `Retry-After` 冷却且保留队列;非法值默认 60 秒,最长一小时。该机制只处理平台 + 瞬时背压,不改变 `BUGCOLLECT_DISABLED` 的永久停用与清队列语义。新增两项解析 + 测试后 bugcollect 独立测试为 14 项。 - common 已增加 `idle / initializing / ready / degraded / failed` 初始化状态、 `XuqmError` 结构化错误、严格 Schema 的非敏感 LKG,以及最多三次的指数退避抖动。 diff --git a/packages/bugcollect/README.md b/packages/bugcollect/README.md index 5daaae7..7cefba2 100644 --- a/packages/bugcollect/README.md +++ b/packages/bugcollect/README.md @@ -40,6 +40,10 @@ fatal/error 持久队列复用 common 的 AES-GCM 沙箱加密能力,并在设 时直接放弃持久化,绝不降级写入包含堆栈和设备信息的明文。密钥丢失、格式损坏或应用 上下文不匹配时清除不可解密队列。 +服务端返回 `429`/`BUGCOLLECT_RATE_LIMITED` 时,SDK 读取标准 `Retry-After` 并进入 +最长一小时的上传冷却;队列保持不变,冷却期间不重复请求,也不会把限流异常抛给宿主 +业务线程。 + Issue 只有在错误堆栈能唯一匹配当前原生 Bundle state/manifest 时才携带 `buildId/moduleId/moduleVersion/bundleHash` 并标记 `symbolicationStatus=exact`;无法归属时明确为 `unavailable`,服务端不得猜测 latest diff --git a/packages/bugcollect/src/queue/LogQueue.ts b/packages/bugcollect/src/queue/LogQueue.ts index 2a4f93e..36e33fd 100644 --- a/packages/bugcollect/src/queue/LogQueue.ts +++ b/packages/bugcollect/src/queue/LogQueue.ts @@ -11,6 +11,7 @@ import { sealQueue, storedQueueKey, } from './queueStorage' +import { parseRetryAfterMillis } from './backpressure' const BATCH_SIZE = 30 const FLUSH_INTERVAL_MS = 10_000 // 10 seconds const MAX_QUEUE_SIZE = 500 @@ -22,11 +23,20 @@ export class BugCollectDisabledError extends Error { readonly code = DISABLED_ERROR_CODE } +class BugCollectRateLimitedError extends Error { + readonly name = 'BugCollectRateLimitedError' + + constructor(readonly retryAfterMs: number) { + super('BUGCOLLECT_RATE_LIMITED') + } +} + export class LogQueue { private _memory: BugCollectEvent[] = [] private flushTimer: ReturnType | null = null private disabled = false private flushPromise: Promise | null = null + private cooldownUntil = 0 constructor( private cfg: { @@ -67,6 +77,7 @@ export class LogQueue { private async _flushOnce(throwOnFailure: boolean): Promise { if (this.disabled) return + if (Date.now() < this.cooldownUntil) return const stored = await this._readStored() const combined = [...stored, ...this._memory] if (combined.length === 0) return @@ -86,7 +97,9 @@ export class LogQueue { }, { maxAttempts: 3, - shouldRetry: error => !(error instanceof BugCollectDisabledError), + shouldRetry: error => + !(error instanceof BugCollectDisabledError) && + !(error instanceof BugCollectRateLimitedError), }, ) await this._writeStored(stored.slice(storedUsed)) @@ -96,6 +109,9 @@ export class LogQueue { this.disabled = true await this.clear() await this.cfg.onDisabled?.() + } else if (error instanceof BugCollectRateLimitedError) { + // 服务端过载时保留原队列,并在冷却期内停止定时器制造额外请求。 + this.cooldownUntil = Date.now() + error.retryAfterMs } // 后台上报失败由队列吸收;保留原队列,等待网络恢复或下次进程启动。 if (throwOnFailure) throw error @@ -141,6 +157,9 @@ export class LogQueue { if (disabledByPlatform || code === DISABLED_ERROR_CODE) { throw new BugCollectDisabledError() } + if (res.status === 429 || code === 'BUGCOLLECT_RATE_LIMITED') { + throw new BugCollectRateLimitedError(parseRetryAfterMillis(res.headers.get('Retry-After'))) + } throw new Error(`[BugCollect] Upload failed: ${res.status}`) } } diff --git a/packages/bugcollect/src/queue/backpressure.ts b/packages/bugcollect/src/queue/backpressure.ts new file mode 100644 index 0000000..2110f2b --- /dev/null +++ b/packages/bugcollect/src/queue/backpressure.ts @@ -0,0 +1,11 @@ +const DEFAULT_RETRY_AFTER_MS = 60_000 +const MAX_RETRY_AFTER_MS = 60 * 60_000 + +/** 兼容 Retry-After 的秒数与 HTTP-date 两种标准格式,并限制异常服务端值。 */ +export function parseRetryAfterMillis(value: string | null, now = Date.now()): number { + if (!value) return DEFAULT_RETRY_AFTER_MS + const seconds = Number(value) + const millis = Number.isFinite(seconds) ? seconds * 1_000 : Date.parse(value) - now + if (!Number.isFinite(millis) || millis <= 0) return DEFAULT_RETRY_AFTER_MS + return Math.min(MAX_RETRY_AFTER_MS, Math.max(1_000, Math.ceil(millis))) +} diff --git a/packages/bugcollect/tests/backpressure.test.ts b/packages/bugcollect/tests/backpressure.test.ts new file mode 100644 index 0000000..c1b070d --- /dev/null +++ b/packages/bugcollect/tests/backpressure.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { parseRetryAfterMillis } from '../src/queue/backpressure' + +test('Retry-After supports seconds and HTTP dates', () => { + const now = Date.parse('2026-07-28T00:00:00Z') + assert.equal(parseRetryAfterMillis('27', now), 27_000) + assert.equal(parseRetryAfterMillis('Tue, 28 Jul 2026 00:00:45 GMT', now), 45_000) +}) + +test('invalid or extreme Retry-After values stay bounded', () => { + assert.equal(parseRetryAfterMillis(null), 60_000) + assert.equal(parseRetryAfterMillis('invalid'), 60_000) + assert.equal(parseRetryAfterMillis('999999'), 60 * 60_000) +})