Skip to content

Commit e7c2b90

Browse files
committed
feat: implement robust GitHub device flow polling with rate limit handling
- Introduce `DevicePollResult` sealed interface to explicitly handle `Success`, `Pending`, `SlowDown`, and `Failed` states during device token polling. - Update `AuthenticationViewModel` to handle `slow_down` responses by dynamically increasing the polling interval and adding a 1-second safety buffer. - Enhance `AuthenticationState` and `AuthenticationRoot` UI to display a "Rate limited" message with a countdown when polling is throttled. - Refactor `AuthenticationRepository` to return the new structured `DevicePollResult` instead of a nullable `Result`. - Improve error categorization and logging for device code verification, specifically distinguishing between pending authorization and terminal failures like expired codes or access denial. - Update string resources to include localized text for rate-limiting notifications.
1 parent 1fc2b81 commit e7c2b90

7 files changed

Lines changed: 87 additions & 46 deletions

File tree

core/presentation/src/commonMain/composeResources/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,7 @@
453453
<string name="auth_hint_denied">You denied the authorization request. Try again if this was unintentional.</string>
454454
<string name="auth_check_status">I already authorized</string>
455455
<string name="auth_polling_status">Checking…</string>
456+
<string name="auth_rate_limited">Rate limited — retrying in %1$ds</string>
456457

457458
<!-- Read More / Show Less -->
458459
<string name="read_more">Read More</string>

feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/network/GitHubAuthApi.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,8 @@ object GitHubAuthApi {
117117
)
118118
}
119119
val status = res.status
120-
val text = res.body<String>()
120+
val text = res.bodyAsText()
121+
121122

122123
if (status !in HttpStatusCode.OK..HttpStatusCode.MultipleChoices) {
123124
return Result.failure(
@@ -129,8 +130,9 @@ object GitHubAuthApi {
129130

130131
try {
131132
val ok = json.decodeFromString(GithubDeviceTokenSuccessDto.serializer(), text)
133+
132134
Result.success(ok)
133-
} catch (_: Throwable) {
135+
} catch (e: Throwable) {
134136
val err = json.decodeFromString(GithubDeviceTokenErrorDto.serializer(), text)
135137
val message =
136138
buildString {
@@ -141,9 +143,11 @@ object GitHubAuthApi {
141143
append(desc)
142144
}
143145
}
146+
144147
Result.failure(IllegalStateException(message))
145148
}
146149
} catch (e: Exception) {
150+
147151
Result.failure(e)
148152
}
149153
}

feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/repository/AuthenticationRepositoryImpl.kt

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import kotlinx.coroutines.isActive
1111
import kotlinx.coroutines.withContext
1212
import zed.rainxch.auth.data.network.GitHubAuthApi
1313
import zed.rainxch.auth.domain.repository.AuthenticationRepository
14+
import zed.rainxch.auth.domain.repository.DevicePollResult
1415
import zed.rainxch.core.data.data_source.TokenStore
1516
import zed.rainxch.core.data.mappers.toData
1617
import zed.rainxch.core.data.mappers.toDomain
@@ -223,7 +224,7 @@ class AuthenticationRepositoryImpl(
223224
}
224225
}
225226

226-
override suspend fun pollDeviceTokenOnce(deviceCode: String): Result<GithubDeviceTokenSuccess?> =
227+
override suspend fun pollDeviceTokenOnce(deviceCode: String): DevicePollResult =
227228
withContext(Dispatchers.IO) {
228229
val clientId = BuildKonfig.GITHUB_CLIENT_ID
229230
try {
@@ -233,48 +234,53 @@ class AuthenticationRepositoryImpl(
233234
if (success != null) {
234235
logger.debug("✅ Single poll: Token received! Saving...")
235236
saveTokenWithVerification(success)
236-
Result.success(success)
237+
DevicePollResult.Success(success)
237238
} else {
238239
val error = res.exceptionOrNull()
239240
val errorMsg = (error?.message ?: "").lowercase()
240241

241242
when {
242-
"authorization_pending" in errorMsg || "slow_down" in errorMsg -> {
243-
Result.success(null)
243+
"slow_down" in errorMsg -> {
244+
logger.debug("⚠️ GitHub says slow down")
245+
DevicePollResult.SlowDown
246+
}
247+
248+
"authorization_pending" in errorMsg -> {
249+
DevicePollResult.Pending
244250
}
245251

246252
"access_denied" in errorMsg -> {
247-
Result.failure(
253+
DevicePollResult.Failed(
248254
Exception("Authentication was denied. Please try again if this was a mistake."),
249255
)
250256
}
251257

252258
"expired_token" in errorMsg ||
253259
"expired_device_code" in errorMsg ||
254260
"token_expired" in errorMsg -> {
255-
Result.failure(
261+
DevicePollResult.Failed(
256262
Exception("Authorization code expired. Please try again."),
257263
)
258264
}
259265

260266
"bad_verification_code" in errorMsg ||
261267
"incorrect_device_code" in errorMsg -> {
262-
Result.failure(
268+
DevicePollResult.Failed(
263269
Exception("Invalid verification code. Please restart authentication."),
264270
)
265271
}
266272

267273
else -> {
268274
logger.debug("⚠️ Single poll unknown error: $errorMsg")
269-
Result.success(null)
275+
DevicePollResult.Pending
270276
}
271277
}
272278
}
273279
} catch (e: CancellationException) {
274280
throw e
275281
} catch (e: Exception) {
276282
logger.debug("⚠️ Single poll network error: ${e.message}")
277-
Result.success(null)
283+
DevicePollResult.Pending
278284
}
279285
}
280286

feature/auth/domain/src/commonMain/kotlin/zed/rainxch/auth/domain/repository/AuthenticationRepository.kt

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@ interface AuthenticationRepository {
1111

1212
suspend fun awaitDeviceToken(start: GithubDeviceStart): GithubDeviceTokenSuccess
1313

14-
/**
15-
* Single poll attempt. Returns:
16-
* - [Result.success] with non-null [GithubDeviceTokenSuccess] if user authorized
17-
* - [Result.success] with null if authorization is still pending (keep polling)
18-
* - [Result.failure] on terminal errors (denied, expired, invalid code)
19-
*/
20-
suspend fun pollDeviceTokenOnce(deviceCode: String): Result<GithubDeviceTokenSuccess?>
14+
suspend fun pollDeviceTokenOnce(deviceCode: String): DevicePollResult
15+
}
16+
17+
sealed interface DevicePollResult {
18+
data class Success(val token: GithubDeviceTokenSuccess) : DevicePollResult
19+
20+
data object Pending : DevicePollResult
21+
22+
data object SlowDown : DevicePollResult
23+
24+
data class Failed(val error: Throwable) : DevicePollResult
2125
}

feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import zed.rainxch.githubstore.core.presentation.res.auth_check_status
8282
import zed.rainxch.githubstore.core.presentation.res.auth_code_expires_in
8383
import zed.rainxch.githubstore.core.presentation.res.auth_error_with_message
8484
import zed.rainxch.githubstore.core.presentation.res.auth_polling_status
85+
import zed.rainxch.githubstore.core.presentation.res.auth_rate_limited
8586
import zed.rainxch.githubstore.core.presentation.res.continue_as_guest
8687
import zed.rainxch.githubstore.core.presentation.res.copy_code
8788
import zed.rainxch.githubstore.core.presentation.res.enter_code_on_github
@@ -532,6 +533,15 @@ private fun StateDevicePrompt(
532533
)
533534
}
534535

536+
if (state.pollIntervalSec > 0) {
537+
Spacer(Modifier.height(8.dp))
538+
Text(
539+
text = stringResource(Res.string.auth_rate_limited, state.pollIntervalSec),
540+
style = MaterialTheme.typography.labelSmall,
541+
color = MaterialTheme.colorScheme.onSurfaceVariant,
542+
)
543+
}
544+
535545
Spacer(Modifier.weight(2f))
536546
}
537547
}

feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationState.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ data class AuthenticationState(
77
val copied: Boolean = false,
88
val info: String? = null,
99
val isPolling: Boolean = false,
10+
val pollIntervalSec: Int = 0,
1011
)

feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import kotlinx.coroutines.launch
2020
import kotlinx.coroutines.withContext
2121
import org.jetbrains.compose.resources.getString
2222
import zed.rainxch.auth.domain.repository.AuthenticationRepository
23+
import zed.rainxch.auth.domain.repository.DevicePollResult
2324
import zed.rainxch.auth.presentation.mapper.toUi
2425
import zed.rainxch.auth.presentation.model.AuthLoginState
2526
import zed.rainxch.auth.presentation.model.GithubDeviceStartUi
@@ -39,6 +40,7 @@ class AuthenticationViewModel(
3940
private var hasLoadedInitialData = false
4041
private var countdownJob: Job? = null
4142
private var pollingJob: Job? = null
43+
private var pollingIntervalMs: Long = DEFAULT_POLL_INTERVAL_SEC * 1000L
4244

4345
private val _state: MutableStateFlow<AuthenticationState> =
4446
MutableStateFlow(AuthenticationState())
@@ -234,24 +236,21 @@ class AuthenticationViewModel(
234236

235237
private fun startPolling(deviceCode: String) {
236238
pollingJob?.cancel()
237-
val intervalMs = getPollingIntervalMs()
239+
val loginState = _state.value.loginState
240+
val intervalSec =
241+
(loginState as? AuthLoginState.DevicePrompt)?.start?.intervalSec
242+
?: DEFAULT_POLL_INTERVAL_SEC
243+
// Add 1s buffer above GitHub's minimum to avoid immediate slow_down
244+
pollingIntervalMs = (intervalSec * 1000).toLong() + 1000L
238245
pollingJob =
239246
viewModelScope.launch {
240247
while (isActive) {
241-
delay(intervalMs)
248+
delay(pollingIntervalMs)
242249
doPoll(deviceCode)
243250
}
244251
}
245252
}
246253

247-
private fun getPollingIntervalMs(): Long {
248-
val loginState = _state.value.loginState
249-
val intervalSec =
250-
(loginState as? AuthLoginState.DevicePrompt)?.start?.intervalSec
251-
?: DEFAULT_POLL_INTERVAL_SEC
252-
return (intervalSec * 1000).toLong()
253-
}
254-
255254
private fun pollOnce(deviceCode: String) {
256255
viewModelScope.launch {
257256
doPoll(deviceCode)
@@ -261,33 +260,48 @@ class AuthenticationViewModel(
261260
private suspend fun doPoll(deviceCode: String) {
262261
_state.update { it.copy(isPolling = true) }
263262
try {
264-
logger.debug("Polling device token (code=${deviceCode.take(8)}...)")
263+
logger.debug("Polling device token (code=${deviceCode.take(8)}..., interval=${pollingIntervalMs}ms)")
265264
val result =
266265
withContext(Dispatchers.IO) {
267266
authenticationRepository.pollDeviceTokenOnce(deviceCode)
268267
}
269268

270-
result
271-
.onSuccess { token ->
272-
if (token != null) {
273-
logger.debug("Poll success — token received, navigating")
274-
pollingJob?.cancel()
275-
countdownJob?.cancel()
276-
clearSavedState()
277-
_state.update {
278-
it.copy(loginState = AuthLoginState.LoggedIn, isPolling = false)
279-
}
280-
_events.trySend(AuthenticationEvents.OnNavigateToMain)
281-
} else {
282-
logger.debug("Poll result: still pending")
283-
_state.update { it.copy(isPolling = false) }
269+
when (result) {
270+
is DevicePollResult.Success -> {
271+
logger.debug("Poll success — token received, navigating")
272+
pollingJob?.cancel()
273+
countdownJob?.cancel()
274+
clearSavedState()
275+
_state.update {
276+
it.copy(loginState = AuthLoginState.LoggedIn, isPolling = false)
284277
}
285-
}.onFailure { error ->
286-
logger.debug("Poll failed terminally: ${error.message}")
278+
_events.trySend(AuthenticationEvents.OnNavigateToMain)
279+
}
280+
281+
is DevicePollResult.Pending -> {
282+
logger.debug("Poll result: still pending")
283+
_state.update { it.copy(isPolling = false, pollIntervalSec = 0) }
284+
}
285+
286+
is DevicePollResult.SlowDown -> {
287+
pollingIntervalMs += 5000L
288+
logger.debug("Poll result: slow_down — increased interval to ${pollingIntervalMs}ms")
289+
_state.update {
290+
it.copy(
291+
isPolling = false,
292+
pollIntervalSec = (pollingIntervalMs / 1000).toInt(),
293+
)
294+
}
295+
// Don't restart — the existing polling loop reads pollingIntervalMs
296+
// on each iteration via delay(), so it will pick up the new value.
297+
}
298+
299+
is DevicePollResult.Failed -> {
300+
logger.debug("Poll failed terminally: ${result.error.message}")
287301
pollingJob?.cancel()
288302
countdownJob?.cancel()
289303
clearSavedState()
290-
val (message, hint) = categorizeError(error)
304+
val (message, hint) = categorizeError(result.error)
291305
_state.update {
292306
it.copy(
293307
loginState =
@@ -296,6 +310,7 @@ class AuthenticationViewModel(
296310
)
297311
}
298312
}
313+
}
299314
} catch (e: CancellationException) {
300315
throw e
301316
} catch (t: Throwable) {

0 commit comments

Comments
 (0)