feat(bugcollect): honor server backpressure
这个提交包含在:
父节点
21ff207ade
当前提交
28a636821a
@ -24,6 +24,10 @@
|
|||||||
`git diff --check` 均通过。RN Android 原生 Bridge 尚未基于本轮 Android
|
`git diff --check` 均通过。RN Android 原生 Bridge 尚未基于本轮 Android
|
||||||
`sdk-update` 制品执行宿主编译,因为本轮 Maven 快照尚未经 Jenkins 发布;不得据此
|
`sdk-update` 制品执行宿主编译,因为本轮 Maven 快照尚未经 Jenkins 发布;不得据此
|
||||||
宣称原生集成已验证。
|
宣称原生集成已验证。
|
||||||
|
- BugCollect 上传器已识别 HTTP 429 与 `BUGCOLLECT_RATE_LIMITED`,按
|
||||||
|
`Retry-After` 冷却且保留队列;非法值默认 60 秒,最长一小时。该机制只处理平台
|
||||||
|
瞬时背压,不改变 `BUGCOLLECT_DISABLED` 的永久停用与清队列语义。新增两项解析
|
||||||
|
测试后 bugcollect 独立测试为 14 项。
|
||||||
|
|
||||||
- common 已增加 `idle / initializing / ready / degraded / failed` 初始化状态、
|
- common 已增加 `idle / initializing / ready / degraded / failed` 初始化状态、
|
||||||
`XuqmError` 结构化错误、严格 Schema 的非敏感 LKG,以及最多三次的指数退避抖动。
|
`XuqmError` 结构化错误、严格 Schema 的非敏感 LKG,以及最多三次的指数退避抖动。
|
||||||
|
|||||||
@ -40,6 +40,10 @@ fatal/error 持久队列复用 common 的 AES-GCM 沙箱加密能力,并在设
|
|||||||
时直接放弃持久化,绝不降级写入包含堆栈和设备信息的明文。密钥丢失、格式损坏或应用
|
时直接放弃持久化,绝不降级写入包含堆栈和设备信息的明文。密钥丢失、格式损坏或应用
|
||||||
上下文不匹配时清除不可解密队列。
|
上下文不匹配时清除不可解密队列。
|
||||||
|
|
||||||
|
服务端返回 `429`/`BUGCOLLECT_RATE_LIMITED` 时,SDK 读取标准 `Retry-After` 并进入
|
||||||
|
最长一小时的上传冷却;队列保持不变,冷却期间不重复请求,也不会把限流异常抛给宿主
|
||||||
|
业务线程。
|
||||||
|
|
||||||
Issue 只有在错误堆栈能唯一匹配当前原生 Bundle
|
Issue 只有在错误堆栈能唯一匹配当前原生 Bundle
|
||||||
state/manifest 时才携带 `buildId/moduleId/moduleVersion/bundleHash` 并标记
|
state/manifest 时才携带 `buildId/moduleId/moduleVersion/bundleHash` 并标记
|
||||||
`symbolicationStatus=exact`;无法归属时明确为 `unavailable`,服务端不得猜测 latest
|
`symbolicationStatus=exact`;无法归属时明确为 `unavailable`,服务端不得猜测 latest
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
sealQueue,
|
sealQueue,
|
||||||
storedQueueKey,
|
storedQueueKey,
|
||||||
} from './queueStorage'
|
} from './queueStorage'
|
||||||
|
import { parseRetryAfterMillis } from './backpressure'
|
||||||
const BATCH_SIZE = 30
|
const BATCH_SIZE = 30
|
||||||
const FLUSH_INTERVAL_MS = 10_000 // 10 seconds
|
const FLUSH_INTERVAL_MS = 10_000 // 10 seconds
|
||||||
const MAX_QUEUE_SIZE = 500
|
const MAX_QUEUE_SIZE = 500
|
||||||
@ -22,11 +23,20 @@ export class BugCollectDisabledError extends Error {
|
|||||||
readonly code = DISABLED_ERROR_CODE
|
readonly code = DISABLED_ERROR_CODE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class BugCollectRateLimitedError extends Error {
|
||||||
|
readonly name = 'BugCollectRateLimitedError'
|
||||||
|
|
||||||
|
constructor(readonly retryAfterMs: number) {
|
||||||
|
super('BUGCOLLECT_RATE_LIMITED')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class LogQueue {
|
export class LogQueue {
|
||||||
private _memory: BugCollectEvent[] = []
|
private _memory: BugCollectEvent[] = []
|
||||||
private flushTimer: ReturnType<typeof setInterval> | null = null
|
private flushTimer: ReturnType<typeof setInterval> | null = null
|
||||||
private disabled = false
|
private disabled = false
|
||||||
private flushPromise: Promise<void> | null = null
|
private flushPromise: Promise<void> | null = null
|
||||||
|
private cooldownUntil = 0
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private cfg: {
|
private cfg: {
|
||||||
@ -67,6 +77,7 @@ export class LogQueue {
|
|||||||
|
|
||||||
private async _flushOnce(throwOnFailure: boolean): Promise<void> {
|
private async _flushOnce(throwOnFailure: boolean): Promise<void> {
|
||||||
if (this.disabled) return
|
if (this.disabled) return
|
||||||
|
if (Date.now() < this.cooldownUntil) return
|
||||||
const stored = await this._readStored()
|
const stored = await this._readStored()
|
||||||
const combined = [...stored, ...this._memory]
|
const combined = [...stored, ...this._memory]
|
||||||
if (combined.length === 0) return
|
if (combined.length === 0) return
|
||||||
@ -86,7 +97,9 @@ export class LogQueue {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
maxAttempts: 3,
|
maxAttempts: 3,
|
||||||
shouldRetry: error => !(error instanceof BugCollectDisabledError),
|
shouldRetry: error =>
|
||||||
|
!(error instanceof BugCollectDisabledError) &&
|
||||||
|
!(error instanceof BugCollectRateLimitedError),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await this._writeStored(stored.slice(storedUsed))
|
await this._writeStored(stored.slice(storedUsed))
|
||||||
@ -96,6 +109,9 @@ export class LogQueue {
|
|||||||
this.disabled = true
|
this.disabled = true
|
||||||
await this.clear()
|
await this.clear()
|
||||||
await this.cfg.onDisabled?.()
|
await this.cfg.onDisabled?.()
|
||||||
|
} else if (error instanceof BugCollectRateLimitedError) {
|
||||||
|
// 服务端过载时保留原队列,并在冷却期内停止定时器制造额外请求。
|
||||||
|
this.cooldownUntil = Date.now() + error.retryAfterMs
|
||||||
}
|
}
|
||||||
// 后台上报失败由队列吸收;保留原队列,等待网络恢复或下次进程启动。
|
// 后台上报失败由队列吸收;保留原队列,等待网络恢复或下次进程启动。
|
||||||
if (throwOnFailure) throw error
|
if (throwOnFailure) throw error
|
||||||
@ -141,6 +157,9 @@ export class LogQueue {
|
|||||||
if (disabledByPlatform || code === DISABLED_ERROR_CODE) {
|
if (disabledByPlatform || code === DISABLED_ERROR_CODE) {
|
||||||
throw new BugCollectDisabledError()
|
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}`)
|
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)
|
||||||
|
})
|
||||||
正在加载...
在新工单中引用
屏蔽一个用户