feat(bugcollect): honor server backpressure
这个提交包含在:
父节点
21ff207ade
当前提交
28a636821a
@ -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)
|
||||
})
|
||||
正在加载...
在新工单中引用
屏蔽一个用户