diff --git a/docs/IMPLEMENTATION_HANDOFF.md b/docs/IMPLEMENTATION_HANDOFF.md index aca725b..ed35d7b 100644 --- a/docs/IMPLEMENTATION_HANDOFF.md +++ b/docs/IMPLEMENTATION_HANDOFF.md @@ -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` 是配置、会话、网络、文件、时间等公共能力的唯一实现。 diff --git a/sdk-bugcollect/README.md b/sdk-bugcollect/README.md index a76f879..dbb03f4 100644 --- a/sdk-bugcollect/README.md +++ b/sdk-bugcollect/README.md @@ -2,6 +2,9 @@ XuqmGroup Android SDK Bug 采集模块。提供错误采集、Crash 捕获、漏斗分析能力。 +服务端返回 HTTP 429 或 `BUGCOLLECT_RATE_LIMITED` 时,SDK 按 `Retry-After` 暂停后台 +上传(最长一小时),保留本地队列且不向宿主业务线程抛异常。 + ## 依赖 ```kotlin diff --git a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/LogQueue.kt b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/LogQueue.kt index cbeec35..f42c087 100644 --- a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/LogQueue.kt +++ b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/LogQueue.kt @@ -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 val events: List 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) { diff --git a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/LogUploader.kt b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/LogUploader.kt index 06b20a4..8ebb144 100644 --- a/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/LogUploader.kt +++ b/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/internal/LogUploader.kt @@ -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 + } +} diff --git a/sdk-bugcollect/src/test/java/com/xuqm/sdk/bugcollect/internal/RetryAfterTest.kt b/sdk-bugcollect/src/test/java/com/xuqm/sdk/bugcollect/internal/RetryAfterTest.kt new file mode 100644 index 0000000..7fc1738 --- /dev/null +++ b/sdk-bugcollect/src/test/java/com/xuqm/sdk/bugcollect/internal/RetryAfterTest.kt @@ -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")) + } +}