diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt index b51d361bb..15a48b630 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt @@ -95,17 +95,18 @@ internal class SimklApiClient( } var syncWriteLockRetried = false for (attempt in 0 until maxAttempts) { - awaitRateLimit(request.method) val response = try { - engine.execute( - method = request.method.name, - url = buildSimklApiUrl(request.path, request.query), - headers = simklRequestHeaders( - accessToken = token, - contentTypeJson = request.method == SimklHttpMethod.POST, - ), - body = request.body, - ) + executeRateLimited(request.method) { + engine.execute( + method = request.method.name, + url = buildSimklApiUrl(request.path, request.query), + headers = simklRequestHeaders( + accessToken = token, + contentTypeJson = request.method == SimklHttpMethod.POST, + ), + body = request.body, + ) + } } catch (error: CancellationException) { throw error } catch (error: Throwable) { @@ -165,17 +166,36 @@ internal class SimklApiClient( error("Simkl request loop completed without a response") } + private suspend fun executeRateLimited( + method: SimklHttpMethod, + block: suspend () -> T, + ): T { + awaitRateLimit(method) + return try { + block() + } finally { + recordRateLimitCompletion(method) + } + } + private suspend fun awaitRateLimit(method: SimklHttpMethod) { val now = nowEpochMs() val scheduledAt = when (method) { - SimklHttpMethod.GET -> max(now, nextGetAtEpochMs) - SimklHttpMethod.POST, SimklHttpMethod.DELETE -> max(now, nextPostAtEpochMs) + SimklHttpMethod.GET -> nextGetAtEpochMs + SimklHttpMethod.POST, SimklHttpMethod.DELETE -> nextPostAtEpochMs } if (scheduledAt > now) sleep(scheduledAt - now) - val requestAt = max(scheduledAt, nowEpochMs()) + } + + private fun recordRateLimitCompletion(method: SimklHttpMethod) { + val completedAt = nowEpochMs() when (method) { - SimklHttpMethod.GET -> nextGetAtEpochMs = requestAt + GET_INTERVAL_MS - SimklHttpMethod.POST, SimklHttpMethod.DELETE -> nextPostAtEpochMs = requestAt + POST_INTERVAL_MS + SimklHttpMethod.GET -> { + nextGetAtEpochMs = max(nextGetAtEpochMs, completedAt + GET_INTERVAL_MS) + } + SimklHttpMethod.POST, SimklHttpMethod.DELETE -> { + nextPostAtEpochMs = max(nextPostAtEpochMs, completedAt + POST_INTERVAL_MS) + } } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt index b51b0c283..839b6141f 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt @@ -173,6 +173,26 @@ class SimklApiClientTest { assertEquals(listOf(0L, 100L, 100L, 1_100L), engine.requests.map { it.atEpochMs }) } + @Test + fun `post cooldown starts after the previous response completes`() = runBlocking { + val engine = RecordingEngine(response(200), response(200)) + val harness = TestHarness(engine, responseDurationMs = 400L) + + harness.client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/oauth/token", + body = "{}", + requiresAuthentication = false, + retryPolicy = SimklRetryPolicy.NEVER, + ), + ) + harness.client.execute(SimklApiRequest(SimklHttpMethod.POST, "/users/settings")) + + assertEquals(listOf(1_000L), harness.sleeps) + assertEquals(listOf(0L, 1_400L), engine.requests.map { it.atEpochMs }) + } + @Test fun `transient responses retry sequentially and deterministic errors do not`() = runBlocking { val transientEngine = RecordingEngine(response(503), response(502), response(200)) @@ -290,12 +310,18 @@ class SimklApiClientTest { assertFalse(harness.wasUnauthorized) } - private class TestHarness(engine: RecordingEngine) { + private class TestHarness( + engine: RecordingEngine, + responseDurationMs: Long = 0L, + ) { var now = 0L val sleeps = mutableListOf() var wasUnauthorized = false val client = SimklApiClient( - engine = engine.also { recording -> recording.now = { now } }, + engine = engine.also { recording -> + recording.now = { now } + recording.onResponse = { now += responseDurationMs } + }, accessToken = { "token" }, onUnauthorized = { wasUnauthorized = true }, nowEpochMs = { now }, @@ -311,6 +337,7 @@ class SimklApiClientTest { private val queuedResponses = responses.toMutableList() val requests = mutableListOf() var now: () -> Long = { 0L } + var onResponse: () -> Unit = {} override suspend fun execute( method: String, @@ -319,7 +346,7 @@ class SimklApiClientTest { body: String, ): RawHttpResponse { requests += RecordedRequest(method, url, headers, body, now()) - return queuedResponses.removeAt(0) + return queuedResponses.removeAt(0).also { onResponse() } } }