Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions TEST_STRATEGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,13 @@ RULEUP_ACCEPTANCE=1 DEV_TOKEN_SECRET=... \
인수 테스트는 기본 CI 에서 뺀다 — 실서버 상태를 바꾸므로 PR 마다 돌리면 데이터가 쌓이고
CI 가 남의 네트워크 사정에 인질이 된다.

### 동기화 429 재시도 (#640)

- Repository 테스트에서 서버의 `retryAfterSeconds`가 예외까지 전달되는지 확인한다.
- Worker 결과 판정 테스트에서 양수 대기 시간과 누락·0·음수의 기본 백오프를 구분한다.
- `VerificationSyncSchedulerTest`: WorkManager에 대기 시간과 네트워크 조건이 저장되고 재시도가 연결되며 동기화 취소 시 연결된 지연 작업도 취소되는지 확인한다.
- 실제 기기에서의 지연 실행과 서버 연동은 기기 테스트가 필요하다.

## 갱신 규칙

테스트를 늘리거나 줄이는 PR 은 이 문서도 같이 고친다. 코드와 같은 PR 에 있어야 안 썩는다.
Expand Down
5 changes: 5 additions & 0 deletions verification/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ android {
buildConfig = true
}

testOptions {
unitTests.isIncludeAndroidResources = true
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
Expand Down Expand Up @@ -84,6 +88,7 @@ dependencies {
// 수집·동기화 경로 가시화 로그(디버그 오버레이/Logcat).

testImplementation(kotlin("test-junit"))
testImplementation(libs.robolectric)
testImplementation(libs.kotlinx.serialization.json)
testImplementation(libs.kotlinx.coroutines.test)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ internal enum class SyncOutcome {

// 429/네트워크 등 일시 오류 → Result.retry(백오프)
RETRY,
RETRY_AFTER,
}

internal fun syncOutcomeFor(error: Throwable?): SyncOutcome =
Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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() {
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<SyncTooFrequentException> {
repository.sync(metadata, SignalBatch("2026-01-01T00:00:00Z", emptyList()))
}

assertEquals(123, error.retryAfterSec)
}

@Test
fun `수동 제출 409 ALREADY_VERIFIED 는 도메인 예외로 변환된다`() =
runTest {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -319,7 +357,8 @@ class VerificationRepositoryImplTest {

override suspend fun intro(request: IntroRequest): BaseResponse<IntroResponse> = error("unused")

override suspend fun sync(request: SyncEnvelopeRequest): BaseResponse<SyncResponse> = error("unused")
override suspend fun sync(request: SyncEnvelopeRequest): BaseResponse<SyncResponse> =
BaseResponse(success = false, error = syncError ?: error("unused"))

override suspend fun getProgress(status: String?): BaseResponse<ProgressResponse> = error("unused")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")))
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ class RunSyncUseCase
signalRepository.markSynced(collectedAt)
throw e
} catch (e: SyncTooFrequentException) {
// 429 는 markSynced 없이 전파 → Worker 가 백오프 재시도(전송분 유지).
// 429 는 markSynced 없이 전파해 미전송 신호를 유지한다.
throw e
}

Expand Down
Loading