fix(simkl): pace requests after completion

This commit is contained in:
tapframe 2026-07-24 19:34:52 +05:30
parent c85831df26
commit d8ffd18c4d
2 changed files with 65 additions and 18 deletions

View file

@ -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 <T> 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)
}
}
}

View file

@ -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<Long>()
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<RecordedRequest>()
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() }
}
}