feat(bugcollect): honor server backpressure

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

查看文件

@ -1,6 +1,6 @@
# Android SDK 重构实施接管文档 # Android SDK 重构实施接管文档
> 更新时间2026-07-26 > 更新时间2026-07-28
> 分支:`main` > 分支:`main`
> 发布约束Maven 制品只允许通过 `https://jenkins.xuqinmin.com/` 发布。 > 发布约束Maven 制品只允许通过 `https://jenkins.xuqinmin.com/` 发布。
@ -39,6 +39,16 @@ Sample 全量编译被真实配置门禁按预期拦截:仓库未包含租户
`sample-app/src/main/assets/config/config.xuqmconfig`。未伪造配置,真机、Jenkins 和 `sample-app/src/main/assets/config/config.xuqmconfig`。未伪造配置,真机、Jenkins 和
Maven 发布尚未执行。 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` 是配置、会话、网络、文件、时间等公共能力的唯一实现。 - `sdk-core` 是配置、会话、网络、文件、时间等公共能力的唯一实现。

查看文件

@ -2,6 +2,9 @@
XuqmGroup Android SDK Bug 采集模块。提供错误采集、Crash 捕获、漏斗分析能力。 XuqmGroup Android SDK Bug 采集模块。提供错误采集、Crash 捕获、漏斗分析能力。
服务端返回 HTTP 429 或 `BUGCOLLECT_RATE_LIMITED` 时,SDK 按 `Retry-After` 暂停后台
上传(最长一小时),保留本地队列且不向宿主业务线程抛异常。
## 依赖 ## 依赖
```kotlin ```kotlin

查看文件

@ -49,6 +49,7 @@ internal class LogQueue(
private val lock = Any() private val lock = Any()
private val flushMutex = Mutex() private val flushMutex = Mutex()
@Volatile private var stopped = false @Volatile private var stopped = false
@Volatile private var uploadCooldownUntilMillis = 0L
private var flushJob: Job? = null private var flushJob: Job? = null
init { init {
@ -91,6 +92,7 @@ internal class LogQueue(
disableAndClear() disableAndClear()
return@launch return@launch
} }
is UploadResult.RateLimited -> return@launch
is UploadResult.Failed -> return@launch is UploadResult.Failed -> return@launch
} }
} }
@ -132,6 +134,7 @@ internal class LogQueue(
/** 单飞执行;定时器、满批触发和手动触发不能重复上传同一批事件。 */ /** 单飞执行;定时器、满批触发和手动触发不能重复上传同一批事件。 */
private suspend fun flushOnce() { private suspend fun flushOnce() {
if (stopped || !XuqmSDK.bugCollectEnabled || !XuqmSDK.privacyConsentGranted) return if (stopped || !XuqmSDK.bugCollectEnabled || !XuqmSDK.privacyConsentGranted) return
if (System.currentTimeMillis() < uploadCooldownUntilMillis) return
val issues: List<IssueEvent> val issues: List<IssueEvent>
val events: List<LogEvent> val events: List<LogEvent>
synchronized(lock) { synchronized(lock) {
@ -149,6 +152,7 @@ internal class LogQueue(
disableAndClear() disableAndClear()
return return
} }
is UploadResult.RateLimited -> return
is UploadResult.Failed -> Unit is UploadResult.Failed -> Unit
} }
} }
@ -156,6 +160,7 @@ internal class LogQueue(
when (uploadWithRetry { LogUploader.uploadEvents(requireUrl(), events) }) { when (uploadWithRetry { LogUploader.uploadEvents(requireUrl(), events) }) {
UploadResult.Success -> synchronized(lock) { pendingEvents.removeAll(events.toSet()) } UploadResult.Success -> synchronized(lock) { pendingEvents.removeAll(events.toSet()) }
UploadResult.Disabled -> disableAndClear() UploadResult.Disabled -> disableAndClear()
is UploadResult.RateLimited -> Unit
is UploadResult.Failed -> Unit is UploadResult.Failed -> Unit
} }
} }
@ -166,6 +171,10 @@ internal class LogQueue(
repeat(3) { attempt -> repeat(3) { attempt ->
if (stopped) return UploadResult.Failed() if (stopped) return UploadResult.Failed()
val result = runCatching(block).getOrElse { UploadResult.Failed(it) } 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 if (result !is UploadResult.Failed) return result
last = result last = result
if (attempt < 2) { if (attempt < 2) {

查看文件

@ -17,6 +17,7 @@ import java.util.concurrent.TimeUnit
internal sealed interface UploadResult { internal sealed interface UploadResult {
data object Success : UploadResult data object Success : UploadResult
data object Disabled : UploadResult data object Disabled : UploadResult
data class RateLimited(val retryAfterMillis: Long) : UploadResult
data class Failed(val cause: Throwable? = null) : UploadResult data class Failed(val cause: Throwable? = null) : UploadResult
} }
@ -56,6 +57,10 @@ internal object LogUploader {
val responseBody = response.body?.string().orEmpty() val responseBody = response.body?.string().orEmpty()
if (isDisabled(responseBody)) { if (isDisabled(responseBody)) {
UploadResult.Disabled UploadResult.Disabled
} else if (response.code == 429 || isRateLimited(responseBody)) {
UploadResult.RateLimited(
RetryAfter.parseMillis(response.header("Retry-After")),
)
} else if (response.isSuccessful) { } else if (response.isSuccessful) {
UploadResult.Success UploadResult.Success
} else { } else {
@ -72,6 +77,14 @@ internal object LogUploader {
.any { it == DISABLED_CODE } .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 { private fun resolveUrl(path: String): String {
val trimmed = path.trimEnd('/') val trimmed = path.trimEnd('/')
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed
@ -91,3 +104,23 @@ internal object LogUploader {
private fun eventToJson(event: LogEvent): JSONObject = private fun eventToJson(event: LogEvent): JSONObject =
JSONObject(com.google.gson.Gson().toJson(event)) 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"))
}
}