feat(bugcollect): honor server backpressure
这个提交包含在:
父节点
8204ee7c38
当前提交
8610ea5745
@ -1,6 +1,6 @@
|
||||
# Android SDK 重构实施接管文档
|
||||
|
||||
> 更新时间:2026-07-26
|
||||
> 更新时间:2026-07-28
|
||||
> 分支:`main`
|
||||
> 发布约束:Maven 制品只允许通过 `https://jenkins.xuqinmin.com/` 发布。
|
||||
|
||||
@ -39,6 +39,16 @@ Sample 全量编译被真实配置门禁按预期拦截:仓库未包含租户
|
||||
`sample-app/src/main/assets/config/config.xuqmconfig`。未伪造配置,真机、Jenkins 和
|
||||
Maven 发布尚未执行。
|
||||
|
||||
## 2026-07-28 BugCollect 摄取防雪崩
|
||||
|
||||
- 原生上传器识别 HTTP 429 与 `BUGCOLLECT_RATE_LIMITED`,按标准 `Retry-After`
|
||||
暂停上传,缺失或非法值默认 60 秒,最长一小时。
|
||||
- 冷却期间定时刷新直接跳过;fatal/error 加密队列和 Crash 文件保持不变,不向宿主
|
||||
业务线程抛异常,也不把临时限流误判为服务永久关闭。
|
||||
- `:sdk-bugcollect:testDebugUnitTest :sdk-bugcollect:lintRelease
|
||||
:sdk-bugcollect:assembleRelease` 已通过。服务端默认每个 appKey 每分钟最多接收
|
||||
6000 个事件,实际部署可通过环境变量调整。
|
||||
|
||||
## 当前目标
|
||||
|
||||
- `sdk-core` 是配置、会话、网络、文件、时间等公共能力的唯一实现。
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
|
||||
XuqmGroup Android SDK Bug 采集模块。提供错误采集、Crash 捕获、漏斗分析能力。
|
||||
|
||||
服务端返回 HTTP 429 或 `BUGCOLLECT_RATE_LIMITED` 时,SDK 按 `Retry-After` 暂停后台
|
||||
上传(最长一小时),保留本地队列且不向宿主业务线程抛异常。
|
||||
|
||||
## 依赖
|
||||
|
||||
```kotlin
|
||||
|
||||
@ -49,6 +49,7 @@ internal class LogQueue(
|
||||
private val lock = Any()
|
||||
private val flushMutex = Mutex()
|
||||
@Volatile private var stopped = false
|
||||
@Volatile private var uploadCooldownUntilMillis = 0L
|
||||
private var flushJob: Job? = null
|
||||
|
||||
init {
|
||||
@ -91,6 +92,7 @@ internal class LogQueue(
|
||||
disableAndClear()
|
||||
return@launch
|
||||
}
|
||||
is UploadResult.RateLimited -> return@launch
|
||||
is UploadResult.Failed -> return@launch
|
||||
}
|
||||
}
|
||||
@ -132,6 +134,7 @@ internal class LogQueue(
|
||||
/** 单飞执行;定时器、满批触发和手动触发不能重复上传同一批事件。 */
|
||||
private suspend fun flushOnce() {
|
||||
if (stopped || !XuqmSDK.bugCollectEnabled || !XuqmSDK.privacyConsentGranted) return
|
||||
if (System.currentTimeMillis() < uploadCooldownUntilMillis) return
|
||||
val issues: List<IssueEvent>
|
||||
val events: List<LogEvent>
|
||||
synchronized(lock) {
|
||||
@ -149,6 +152,7 @@ internal class LogQueue(
|
||||
disableAndClear()
|
||||
return
|
||||
}
|
||||
is UploadResult.RateLimited -> return
|
||||
is UploadResult.Failed -> Unit
|
||||
}
|
||||
}
|
||||
@ -156,6 +160,7 @@ internal class LogQueue(
|
||||
when (uploadWithRetry { LogUploader.uploadEvents(requireUrl(), events) }) {
|
||||
UploadResult.Success -> synchronized(lock) { pendingEvents.removeAll(events.toSet()) }
|
||||
UploadResult.Disabled -> disableAndClear()
|
||||
is UploadResult.RateLimited -> Unit
|
||||
is UploadResult.Failed -> Unit
|
||||
}
|
||||
}
|
||||
@ -166,6 +171,10 @@ internal class LogQueue(
|
||||
repeat(3) { attempt ->
|
||||
if (stopped) return UploadResult.Failed()
|
||||
val result = runCatching(block).getOrElse { UploadResult.Failed(it) }
|
||||
if (result is UploadResult.RateLimited) {
|
||||
uploadCooldownUntilMillis = System.currentTimeMillis() + result.retryAfterMillis
|
||||
return result
|
||||
}
|
||||
if (result !is UploadResult.Failed) return result
|
||||
last = result
|
||||
if (attempt < 2) {
|
||||
|
||||
@ -17,6 +17,7 @@ import java.util.concurrent.TimeUnit
|
||||
internal sealed interface UploadResult {
|
||||
data object Success : UploadResult
|
||||
data object Disabled : UploadResult
|
||||
data class RateLimited(val retryAfterMillis: Long) : UploadResult
|
||||
data class Failed(val cause: Throwable? = null) : UploadResult
|
||||
}
|
||||
|
||||
@ -56,6 +57,10 @@ internal object LogUploader {
|
||||
val responseBody = response.body?.string().orEmpty()
|
||||
if (isDisabled(responseBody)) {
|
||||
UploadResult.Disabled
|
||||
} else if (response.code == 429 || isRateLimited(responseBody)) {
|
||||
UploadResult.RateLimited(
|
||||
RetryAfter.parseMillis(response.header("Retry-After")),
|
||||
)
|
||||
} else if (response.isSuccessful) {
|
||||
UploadResult.Success
|
||||
} else {
|
||||
@ -72,6 +77,14 @@ internal object LogUploader {
|
||||
.any { it == DISABLED_CODE }
|
||||
}
|
||||
|
||||
private fun isRateLimited(body: String): Boolean {
|
||||
if (body.isBlank()) return false
|
||||
val json = runCatching { JSONObject(body) }.getOrNull() ?: return false
|
||||
return sequenceOf("code", "errorCode", "message")
|
||||
.mapNotNull { key -> json.opt(key)?.toString() }
|
||||
.any { it == "BUGCOLLECT_RATE_LIMITED" }
|
||||
}
|
||||
|
||||
private fun resolveUrl(path: String): String {
|
||||
val trimmed = path.trimEnd('/')
|
||||
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed
|
||||
@ -91,3 +104,23 @@ internal object LogUploader {
|
||||
private fun eventToJson(event: LogEvent): JSONObject =
|
||||
JSONObject(com.google.gson.Gson().toJson(event))
|
||||
}
|
||||
|
||||
internal object RetryAfter {
|
||||
private const val DEFAULT_MILLIS = 60_000L
|
||||
private const val MAX_MILLIS = 60L * 60_000L
|
||||
|
||||
fun parseMillis(value: String?, nowMillis: Long = System.currentTimeMillis()): Long {
|
||||
if (value.isNullOrBlank()) return DEFAULT_MILLIS
|
||||
val millis = value.trim().toLongOrNull()?.times(1_000L)
|
||||
?: runCatching {
|
||||
SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("GMT")
|
||||
isLenient = false
|
||||
}.parse(value)?.time?.minus(nowMillis)
|
||||
}.getOrNull()
|
||||
return millis
|
||||
?.takeIf { it > 0L }
|
||||
?.coerceIn(1_000L, MAX_MILLIS)
|
||||
?: DEFAULT_MILLIS
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
package com.xuqm.sdk.bugcollect.internal
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class RetryAfterTest {
|
||||
|
||||
@Test
|
||||
fun parsesSecondsAndHttpDate() {
|
||||
val now = 1_775_000_000_000L
|
||||
assertEquals(27_000L, RetryAfter.parseMillis("27", now))
|
||||
val formatter = java.text.SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", java.util.Locale.US)
|
||||
formatter.timeZone = java.util.TimeZone.getTimeZone("GMT")
|
||||
val date = formatter.format(java.util.Date(now + 45_000L))
|
||||
assertEquals(45_000L, RetryAfter.parseMillis(date, now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidAndExtremeValuesStayBounded() {
|
||||
assertEquals(60_000L, RetryAfter.parseMillis(null))
|
||||
assertEquals(60_000L, RetryAfter.parseMillis("invalid"))
|
||||
assertEquals(60L * 60_000L, RetryAfter.parseMillis("999999"))
|
||||
}
|
||||
}
|
||||
正在加载...
在新工单中引用
屏蔽一个用户