diff --git a/TEST_STRATEGY.md b/TEST_STRATEGY.md index 6b88a8c79..ea4da331c 100644 --- a/TEST_STRATEGY.md +++ b/TEST_STRATEGY.md @@ -188,6 +188,13 @@ RULEUP_ACCEPTANCE=1 DEV_TOKEN_SECRET=... \ 인수 테스트는 기본 CI 에서 뺀다 — 실서버 상태를 바꾸므로 PR 마다 돌리면 데이터가 쌓이고 CI 가 남의 네트워크 사정에 인질이 된다. +### 동기화 429 재시도 (#640) + +- Repository 테스트에서 서버의 `retryAfterSeconds`가 예외까지 전달되는지 확인한다. +- Worker 결과 판정 테스트에서 양수 대기 시간과 누락·0·음수의 기본 백오프를 구분한다. +- `VerificationSyncSchedulerTest`: WorkManager에 대기 시간과 네트워크 조건이 저장되고 재시도가 연결되며 동기화 취소 시 연결된 지연 작업도 취소되는지 확인한다. +- 실제 기기에서의 지연 실행과 서버 연동은 기기 테스트가 필요하다. + ## 갱신 규칙 테스트를 늘리거나 줄이는 PR 은 이 문서도 같이 고친다. 코드와 같은 PR 에 있어야 안 썩는다. diff --git a/verification/data/build.gradle.kts b/verification/data/build.gradle.kts index 2a027b646..4834a5342 100644 --- a/verification/data/build.gradle.kts +++ b/verification/data/build.gradle.kts @@ -30,6 +30,10 @@ android { buildConfig = true } + testOptions { + unitTests.isIncludeAndroidResources = true + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 @@ -84,6 +88,7 @@ dependencies { // 수집·동기화 경로 가시화 로그(디버그 오버레이/Logcat). testImplementation(kotlin("test-junit")) + testImplementation(libs.robolectric) testImplementation(libs.kotlinx.serialization.json) testImplementation(libs.kotlinx.coroutines.test) } diff --git a/verification/data/src/main/kotlin/com/ruleup/verification/data/repository/VerificationRepositoryImpl.kt b/verification/data/src/main/kotlin/com/ruleup/verification/data/repository/VerificationRepositoryImpl.kt index 88e5ec676..6e95c66c8 100644 --- a/verification/data/src/main/kotlin/com/ruleup/verification/data/repository/VerificationRepositoryImpl.kt +++ b/verification/data/src/main/kotlin/com/ruleup/verification/data/repository/VerificationRepositoryImpl.kt @@ -77,7 +77,7 @@ class VerificationRepositoryImpl } catch (e: ApiException) { // 429/413/400 은 호출자가 백오프·분할·폐기로 분기할 수 있도록 도메인 예외로 변환한다. when (e.code) { - CODE_SYNC_TOO_FREQUENT -> throw SyncTooFrequentException() + CODE_SYNC_TOO_FREQUENT -> throw SyncTooFrequentException(e.retryAfterSeconds) CODE_INVALID_SIGNAL_PAYLOAD -> throw InvalidSignalPayloadException() CODE_SYNC_PAYLOAD_TOO_LARGE -> throw SyncPayloadTooLargeException() else -> throw e diff --git a/verification/data/src/main/kotlin/com/ruleup/verification/data/sync/SyncOutcome.kt b/verification/data/src/main/kotlin/com/ruleup/verification/data/sync/SyncOutcome.kt index 7577f61f2..b420f661d 100644 --- a/verification/data/src/main/kotlin/com/ruleup/verification/data/sync/SyncOutcome.kt +++ b/verification/data/src/main/kotlin/com/ruleup/verification/data/sync/SyncOutcome.kt @@ -14,6 +14,7 @@ internal enum class SyncOutcome { // 429/네트워크 등 일시 오류 → Result.retry(백오프) RETRY, + RETRY_AFTER, } internal fun syncOutcomeFor(error: Throwable?): SyncOutcome = @@ -22,6 +23,7 @@ internal fun syncOutcomeFor(error: Throwable?): SyncOutcome = is InvalidSignalPayloadException -> SyncOutcome.STOP_RETRY // 분할까지 했는데도 상한을 넘었다 is SyncPayloadTooLargeException -> SyncOutcome.STOP_RETRY - is SyncTooFrequentException -> SyncOutcome.RETRY + is SyncTooFrequentException -> + if ((error.retryAfterSec ?: 0) > 0) SyncOutcome.RETRY_AFTER else SyncOutcome.RETRY else -> SyncOutcome.RETRY } diff --git a/verification/data/src/main/kotlin/com/ruleup/verification/data/sync/VerificationSyncSchedulerImpl.kt b/verification/data/src/main/kotlin/com/ruleup/verification/data/sync/VerificationSyncSchedulerImpl.kt index 1a26890df..f7b967e94 100644 --- a/verification/data/src/main/kotlin/com/ruleup/verification/data/sync/VerificationSyncSchedulerImpl.kt +++ b/verification/data/src/main/kotlin/com/ruleup/verification/data/sync/VerificationSyncSchedulerImpl.kt @@ -11,6 +11,8 @@ import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager import com.ruleup.verification.domain.repository.SyncScheduler import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -24,6 +26,7 @@ class VerificationSyncSchedulerImpl val manager = WorkManager.getInstance(context) manager.cancelUniqueWork(VerificationSyncWorker.WORK_NAME) manager.cancelUniqueWork(CATCH_UP_WORK_NAME) + manager.cancelUniqueWork(DELAYED_RETRY_WORK_NAME) } override fun ensureScheduled() { @@ -61,6 +64,26 @@ class VerificationSyncSchedulerImpl private const val MIN_INTERVAL_MIN = 15L private const val SECONDS_PER_MINUTE = 60L const val CATCH_UP_WORK_NAME = "verification_sync_catchup" + private const val DELAYED_RETRY_WORK_NAME = "verification_sync_delayed_retry" + + suspend fun enqueueDelayedCatchUp( + context: Context, + delaySeconds: Int, + ) { + val request = + OneTimeWorkRequest + .Builder(VerificationSyncWorker::class.java) + .setInitialDelay(delaySeconds.toLong(), TimeUnit.SECONDS) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .build() + val operation = + WorkManager.getInstance(context).enqueueUniqueWork( + DELAYED_RETRY_WORK_NAME, + ExistingWorkPolicy.APPEND_OR_REPLACE, + request, + ) + withContext(Dispatchers.IO) { operation.result.get() } + } /** push 트리거용 expedited catch-up. */ fun enqueueCatchUp( diff --git a/verification/data/src/main/kotlin/com/ruleup/verification/data/sync/VerificationSyncWorker.kt b/verification/data/src/main/kotlin/com/ruleup/verification/data/sync/VerificationSyncWorker.kt index 2d69410b0..daf021ed4 100644 --- a/verification/data/src/main/kotlin/com/ruleup/verification/data/sync/VerificationSyncWorker.kt +++ b/verification/data/src/main/kotlin/com/ruleup/verification/data/sync/VerificationSyncWorker.kt @@ -19,6 +19,7 @@ import com.ruleup.observability.domain.model.ErrorInfo import com.ruleup.observability.domain.model.Severity import com.ruleup.observability.domain.model.attributes import com.ruleup.verification.data.settings.VerificationSettingsStore +import com.ruleup.verification.domain.entity.SyncTooFrequentException import com.ruleup.verification.domain.repository.ProgressCacheStore import com.ruleup.verification.domain.repository.SyncScheduler import com.ruleup.verification.domain.repository.SyncScopeProvider @@ -102,6 +103,18 @@ class VerificationSyncWorker when (outcome) { SyncOutcome.SUCCESS, SyncOutcome.STOP_RETRY -> Result.success() SyncOutcome.RETRY -> Result.retry() + SyncOutcome.RETRY_AFTER -> { + val seconds = (e as SyncTooFrequentException).retryAfterSec!! + try { + VerificationSyncSchedulerImpl.enqueueDelayedCatchUp(applicationContext, seconds) + Result.success() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + observability.i(LOG_TAG) { "지연 재시도 예약 실패 — 기본 백오프로 재시도" } + Result.retry() + } + } } } } diff --git a/verification/data/src/test/kotlin/com/ruleup/verification/data/repository/VerificationRepositoryImplTest.kt b/verification/data/src/test/kotlin/com/ruleup/verification/data/repository/VerificationRepositoryImplTest.kt index 76b5c8b34..e7d4b40c8 100644 --- a/verification/data/src/test/kotlin/com/ruleup/verification/data/repository/VerificationRepositoryImplTest.kt +++ b/verification/data/src/test/kotlin/com/ruleup/verification/data/repository/VerificationRepositoryImplTest.kt @@ -38,12 +38,22 @@ import com.ruleup.verification.domain.entity.AppealNotFailedException import com.ruleup.verification.domain.entity.AppealTrack import com.ruleup.verification.domain.entity.AppealWindowClosedException import com.ruleup.verification.domain.entity.CancelWindowClosedException +import com.ruleup.verification.domain.entity.CoverageWindow +import com.ruleup.verification.domain.entity.DeviceClock +import com.ruleup.verification.domain.entity.DeviceDiagnostics +import com.ruleup.verification.domain.entity.EnvelopeMetadata +import com.ruleup.verification.domain.entity.IntegritySnapshot import com.ruleup.verification.domain.entity.InvalidAnchorException import com.ruleup.verification.domain.entity.InvalidAppealReasonException import com.ruleup.verification.domain.entity.InvalidTargetDateException import com.ruleup.verification.domain.entity.LocationLockedInWindowException import com.ruleup.verification.domain.entity.LocationPin +import com.ruleup.verification.domain.entity.NetworkState +import com.ruleup.verification.domain.entity.PermissionSnapshot +import com.ruleup.verification.domain.entity.PermissionState import com.ruleup.verification.domain.entity.SettingChangeLimitException +import com.ruleup.verification.domain.entity.SignalBatch +import com.ruleup.verification.domain.entity.SyncTooFrequentException import com.ruleup.verification.domain.entity.TodayResultStatus import kotlinx.coroutines.test.runTest import okhttp3.MultipartBody @@ -54,6 +64,33 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class VerificationRepositoryImplTest { + @Test + fun `sync 제한 응답은 서버가 알려준 대기 시간을 보존한다`() = + runTest { + val api = FakeVerificationApi(syncError = ErrorBody("SYNC_TOO_FREQUENT", "대기", retryAfterSeconds = 123)) + val repository = VerificationRepositoryImpl(api, FakeKakaoLocalApi(), FakeImageReader()) + val denied = PermissionState.DENIED + val metadata = + EnvelopeMetadata( + clock = DeviceClock(1, 1, "boot", "Asia/Seoul"), + deviceId = "device", + activeChallengeIds = emptyList(), + permissions = PermissionSnapshot(denied, denied, denied, denied, denied, denied, denied, denied), + network = NetworkState(vpnActive = false), + integrity = IntegritySnapshot(token = null), + diagnostics = DeviceDiagnostics(null, null, null, null, null, null, "SDK_AVAILABLE"), + gaps = emptyList(), + coverage = CoverageWindow(from = 1, until = 1), + ) + + val error = + assertFailsWith { + repository.sync(metadata, SignalBatch("2026-01-01T00:00:00Z", emptyList())) + } + + assertEquals(123, error.retryAfterSec) + } + @Test fun `수동 제출 409 ALREADY_VERIFIED 는 도메인 예외로 변환된다`() = runTest { @@ -276,6 +313,7 @@ class VerificationRepositoryImplTest { private fun anchorSet(): AnchorSet = AnchorSet.of(listOf(LocationPin(lat = 37.4979, lng = 127.0276, label = "새 헬스장"))) private class FakeVerificationApi( + private val syncError: ErrorBody? = null, private val manualSuccess: ManualSubmitResponse? = null, private val manualError: ErrorBody? = null, private val myLocationSuccess: MyLocationResponse? = null, @@ -319,7 +357,8 @@ class VerificationRepositoryImplTest { override suspend fun intro(request: IntroRequest): BaseResponse = error("unused") - override suspend fun sync(request: SyncEnvelopeRequest): BaseResponse = error("unused") + override suspend fun sync(request: SyncEnvelopeRequest): BaseResponse = + BaseResponse(success = false, error = syncError ?: error("unused")) override suspend fun getProgress(status: String?): BaseResponse = error("unused") diff --git a/verification/data/src/test/kotlin/com/ruleup/verification/data/sync/SyncOutcomeTest.kt b/verification/data/src/test/kotlin/com/ruleup/verification/data/sync/SyncOutcomeTest.kt index a97b61169..87e4963c4 100644 --- a/verification/data/src/test/kotlin/com/ruleup/verification/data/sync/SyncOutcomeTest.kt +++ b/verification/data/src/test/kotlin/com/ruleup/verification/data/sync/SyncOutcomeTest.kt @@ -27,6 +27,18 @@ class SyncOutcomeTest { assertEquals(SyncOutcome.RETRY, syncOutcomeFor(SyncTooFrequentException())) } + @Test + fun `429 대기 시간이 있으면 서버가 허용하는 시각까지 늦춘다`() { + assertEquals(SyncOutcome.RETRY_AFTER, syncOutcomeFor(SyncTooFrequentException(123))) + } + + @Test + fun `429 대기 시간이 없거나 잘못됐으면 기본 백오프로 재시도한다`() { + assertEquals(SyncOutcome.RETRY, syncOutcomeFor(SyncTooFrequentException(null))) + assertEquals(SyncOutcome.RETRY, syncOutcomeFor(SyncTooFrequentException(0))) + assertEquals(SyncOutcome.RETRY, syncOutcomeFor(SyncTooFrequentException(-1))) + } + @Test fun `기타 일시 오류는 RETRY`() { assertEquals(SyncOutcome.RETRY, syncOutcomeFor(RuntimeException("network"))) diff --git a/verification/data/src/test/kotlin/com/ruleup/verification/data/sync/VerificationSyncSchedulerTest.kt b/verification/data/src/test/kotlin/com/ruleup/verification/data/sync/VerificationSyncSchedulerTest.kt new file mode 100644 index 000000000..d6c4b6124 --- /dev/null +++ b/verification/data/src/test/kotlin/com/ruleup/verification/data/sync/VerificationSyncSchedulerTest.kt @@ -0,0 +1,50 @@ +package com.ruleup.verification.data.sync + +import android.app.Application +import androidx.work.Configuration +import androidx.work.NetworkType +import androidx.work.WorkInfo +import androidx.work.WorkManager +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class VerificationSyncSchedulerTest { + @Test + fun `서버 대기 시간과 네트워크 조건으로 예약하고 동기화 취소 시 함께 취소한다`() = + runBlocking { + val context = RuntimeEnvironment.getApplication() + WorkManager.initialize(context, Configuration.Builder().build()) + val manager = WorkManager.getInstance(context) + + VerificationSyncSchedulerImpl.enqueueDelayedCatchUp(context, 123) + + val work = manager.getWorkInfosByTag(VerificationSyncWorker::class.java.name).get(10, TimeUnit.SECONDS).single() + assertEquals(WorkInfo.State.ENQUEUED, work.state) + assertEquals(123_000L, work.initialDelayMillis) + assertEquals(NetworkType.CONNECTED, work.constraints.requiredNetworkType) + + VerificationSyncSchedulerImpl.enqueueDelayedCatchUp(context, 45) + val queued = manager.getWorkInfosByTag(VerificationSyncWorker::class.java.name).get(10, TimeUnit.SECONDS) + assertEquals(2, queued.size) + val successor = queued.single { it.id != work.id } + assertEquals(WorkInfo.State.BLOCKED, successor.state) + assertEquals(45_000L, successor.initialDelayMillis) + + VerificationSyncSchedulerImpl(context).cancel() + val cancelled = withTimeout(10_000) { manager.getWorkInfoByIdFlow(work.id).first { it?.state == WorkInfo.State.CANCELLED } } + assertEquals(WorkInfo.State.CANCELLED, cancelled?.state) + val successorCancelled = + withTimeout(10_000) { manager.getWorkInfoByIdFlow(successor.id).first { it?.state == WorkInfo.State.CANCELLED } } + assertEquals(WorkInfo.State.CANCELLED, successorCancelled?.state) + } +} diff --git a/verification/domain/src/main/kotlin/com/ruleup/verification/domain/usecase/RunSyncUseCase.kt b/verification/domain/src/main/kotlin/com/ruleup/verification/domain/usecase/RunSyncUseCase.kt index 471f543fb..8e797730f 100644 --- a/verification/domain/src/main/kotlin/com/ruleup/verification/domain/usecase/RunSyncUseCase.kt +++ b/verification/domain/src/main/kotlin/com/ruleup/verification/domain/usecase/RunSyncUseCase.kt @@ -56,7 +56,7 @@ class RunSyncUseCase signalRepository.markSynced(collectedAt) throw e } catch (e: SyncTooFrequentException) { - // 429 는 markSynced 없이 전파 → Worker 가 백오프 재시도(전송분 유지). + // 429 는 markSynced 없이 전파해 미전송 신호를 유지한다. throw e }