feat(bugcollect): honor server backpressure

这个提交包含在:
XuqmGroup 2026-07-28 20:41:05 +08:00
父节点 21ff207ade
当前提交 28a636821a
共有 5 个文件被更改,包括 55 次插入1 次删除

查看文件

@ -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,以及最多三次的指数退避抖动。

查看文件

@ -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

查看文件

@ -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<typeof setInterval> | null = null
private disabled = false
private flushPromise: Promise<void> | null = null
private cooldownUntil = 0
constructor(
private cfg: {
@ -67,6 +77,7 @@ export class LogQueue {
private async _flushOnce(throwOnFailure: boolean): Promise<void> {
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}`)
}
}

查看文件

@ -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)))
}

查看文件

@ -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)
})