Skip to content

Commit 9972274

Browse files
committed
refactor(core)!: Centralize rate limit handling with events
This commit refactors the application's rate limit handling to be more robust and centralized. Previously, rate limit checks were scattered, leading to inconsistent behavior. The new approach introduces a `SharedFlow` event system within the `RateLimitRepository`. When the GitHub API rate limit is exceeded, a `RateLimitException` is now thrown immediately from the `HttpClient`'s response handler. This exception is caught in repository implementations and re-thrown, ensuring that network operations are halted promptly. The exception propagates up to ViewModels, which now catch it to prevent crashes and stop loading indicators. A global `rateLimitExhaustedEvent` is emitted from the `RateLimitRepository`, which the `MainViewModel` observes to display a single, consistent rate limit dialog to the user. This avoids showing multiple dialogs or leaving the UI in an inconsistent loading state. - **refactor(core)!**: Modified `HttpClient` to throw a `RateLimitException` immediately upon detection, rather than returning a `Result.failure`. This is a breaking change for repository implementations. - **refactor(core)!**: Introduced `rateLimitExhaustedEvent` as a `SharedFlow` in `RateLimitRepository` to signal when the API limit has been hit. - **feat(app)**: The `MainViewModel` now listens to `rateLimitExhaustedEvent` to globally manage and display the rate limit dialog. - **refactor(details, dev-profile)**: Updated ViewModels and repositories to catch and handle the `RateLimitException`, preventing crashes and ensuring UI loading states are correctly reset. - **fix(details)**: Resolved an issue where the details screen would show a generic error on rate limit instead of gracefully stopping. Data fetched before the limit is now displayed. - **fix(home)**: Prevented a crash when rapidly switching categories by ensuring the previous category-switching job is cancelled before starting a new one. - **chore(files)**: Performed minor project cleanup, including renaming `App.kt` to `Main.kt` and relocating `RateLimitDialog.kt`. - **chore(domain)**: Removed the unused `trendingScore` property from the `GithubRepoSummary` model.
1 parent 3969bcb commit 9972274

13 files changed

Lines changed: 132 additions & 78 deletions

File tree

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/App.kt renamed to composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import zed.rainxch.core.presentation.theme.GithubStoreTheme
1212
import zed.rainxch.core.presentation.utils.ApplyAndroidSystemBars
1313
import zed.rainxch.githubstore.app.navigation.AppNavigation
1414
import zed.rainxch.githubstore.app.navigation.GithubStoreGraph
15-
import zed.rainxch.githubstore.app.state.components.RateLimitDialog
15+
import zed.rainxch.githubstore.app.components.RateLimitDialog
1616

1717
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
1818
@Composable

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,17 @@ class MainViewModel(
7878
viewModelScope.launch {
7979
rateLimitRepository.rateLimitState.collect { rateLimitInfo ->
8080
_state.update { currentState ->
81-
currentState.copy(
82-
rateLimitInfo = rateLimitInfo,
83-
showRateLimitDialog = rateLimitInfo?.isExhausted == true,
84-
)
81+
currentState.copy(rateLimitInfo = rateLimitInfo)
8582
}
8683
}
8784
}
8885

86+
viewModelScope.launch {
87+
rateLimitRepository.rateLimitExhaustedEvent.collect { info ->
88+
_state.update { it.copy(showRateLimitDialog = true, rateLimitInfo = info) }
89+
}
90+
}
91+
8992
viewModelScope.launch(Dispatchers.IO) {
9093
syncUseCase().onSuccess {
9194
installedAppsRepository.checkAllForUpdates()

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/state/components/RateLimitDialog.kt renamed to composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/RateLimitDialog.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package zed.rainxch.githubstore.app.state.components
1+
package zed.rainxch.githubstore.app.components
22

33
import androidx.compose.foundation.layout.Arrangement
44
import androidx.compose.foundation.layout.Column

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/state/AppAction.kt

Lines changed: 0 additions & 5 deletions
This file was deleted.

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/state/AppState.kt

Lines changed: 0 additions & 11 deletions
This file was deleted.

core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import zed.rainxch.core.data.network.interceptor.RateLimitInterceptor
1515
import zed.rainxch.core.domain.model.RateLimitException
1616
import zed.rainxch.core.domain.repository.RateLimitRepository
1717
import java.io.IOException
18+
import kotlin.coroutines.cancellation.CancellationException
1819

1920
fun createGitHubHttpClient(
2021
tokenStore: TokenStore,
@@ -94,7 +95,9 @@ suspend inline fun <reified T> HttpClient.executeRequest(
9495
)
9596
}
9697
} catch (e: RateLimitException) {
97-
Result.failure(e)
98+
throw e
99+
} catch (e: CancellationException) {
100+
throw e
98101
} catch (e: Exception) {
99102
Result.failure(e)
100103
}

core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/RateLimitRepositoryImpl.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package zed.rainxch.core.data.repository
22

3+
import kotlinx.coroutines.flow.MutableSharedFlow
34
import kotlinx.coroutines.flow.MutableStateFlow
5+
import kotlinx.coroutines.flow.SharedFlow
46
import kotlinx.coroutines.flow.StateFlow
57
import kotlinx.coroutines.flow.asStateFlow
68
import zed.rainxch.core.domain.model.RateLimitInfo
@@ -12,8 +14,14 @@ class RateLimitRepositoryImpl : RateLimitRepository {
1214
private val _rateLimitState = MutableStateFlow<RateLimitInfo?>(null)
1315
override val rateLimitState: StateFlow<RateLimitInfo?> = _rateLimitState.asStateFlow()
1416

17+
private val _rateLimitExhaustedEvent = MutableSharedFlow<RateLimitInfo>(extraBufferCapacity = 1)
18+
override val rateLimitExhaustedEvent: SharedFlow<RateLimitInfo> = _rateLimitExhaustedEvent
19+
1520
override fun updateRateLimit(rateLimitInfo: RateLimitInfo?) {
1621
_rateLimitState.value = rateLimitInfo
22+
if (rateLimitInfo?.isExhausted == true) {
23+
_rateLimitExhaustedEvent.tryEmit(rateLimitInfo)
24+
}
1725
}
1826

1927
override fun getCurrentRateLimit(): RateLimitInfo? {

core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubRepoSummary.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,4 @@ data class GithubRepoSummary(
1717
val topics: List<String>?,
1818
val releasesUrl: String,
1919
val updatedAt: String,
20-
val trendingScore: Double? = null
2120
)

core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/RateLimitRepository.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package zed.rainxch.core.domain.repository
22

3+
import kotlinx.coroutines.flow.SharedFlow
34
import kotlinx.coroutines.flow.StateFlow
45
import zed.rainxch.core.domain.model.RateLimitInfo
56

67
interface RateLimitRepository {
78
val rateLimitState: StateFlow<RateLimitInfo?>
9+
val rateLimitExhaustedEvent: SharedFlow<RateLimitInfo>
810

911
fun updateRateLimit(rateLimitInfo: RateLimitInfo?)
1012

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import zed.rainxch.core.domain.model.FavoriteRepo
2525
import zed.rainxch.core.domain.model.InstallSource
2626
import zed.rainxch.core.domain.model.InstalledApp
2727
import zed.rainxch.core.domain.model.Platform
28+
import zed.rainxch.core.domain.model.RateLimitException
2829
import zed.rainxch.core.domain.network.Downloader
2930
import zed.rainxch.core.domain.repository.FavouritesRepository
3031
import zed.rainxch.core.domain.repository.InstalledAppsRepository
@@ -37,6 +38,7 @@ import zed.rainxch.details.domain.repository.DetailsRepository
3738
import zed.rainxch.details.presentation.model.DownloadStage
3839
import zed.rainxch.details.presentation.model.InstallLogItem
3940
import zed.rainxch.details.presentation.model.LogResult
41+
import java.util.concurrent.atomic.AtomicBoolean
4042
import kotlin.time.Clock.System
4143
import kotlin.time.ExperimentalTime
4244

@@ -76,6 +78,8 @@ class DetailsViewModel(
7678
private val _events = Channel<DetailsEvent>()
7779
val events = _events.receiveAsFlow()
7880

81+
val rateLimited = AtomicBoolean(false)
82+
7983
@OptIn(ExperimentalTime::class)
8084
private fun loadInitial() {
8185
viewModelScope.launch {
@@ -91,6 +95,9 @@ class DetailsViewModel(
9195
val isFavoriteDeferred = async {
9296
try {
9397
favouritesRepository.isFavoriteSync(repo.id)
98+
} catch (_: RateLimitException) {
99+
rateLimited.set(true)
100+
null
94101
} catch (t: Throwable) {
95102
logger.error("Failed to load if repo is favourite: ${t.localizedMessage}")
96103
false
@@ -100,6 +107,9 @@ class DetailsViewModel(
100107
val isStarredDeferred = async {
101108
try {
102109
starredRepository.isStarred(repo.id)
110+
} catch (_: RateLimitException) {
111+
rateLimited.set(true)
112+
null
103113
} catch (t: Throwable) {
104114
logger.error("Failed to load if repo is starred: ${t.localizedMessage}")
105115
false
@@ -112,17 +122,18 @@ class DetailsViewModel(
112122

113123
_state.value = _state.value.copy(
114124
repository = repo,
115-
isFavourite = isFavorite,
116-
isStarred = isStarred,
125+
isFavourite = isFavorite == true,
126+
isStarred = isStarred == true,
117127
)
118128

119129
val latestReleaseDeferred = async {
120130
try {
121131
detailsRepository.getLatestPublishedRelease(
122-
owner = owner,
123-
repo = name,
124-
defaultBranch = repo.defaultBranch
132+
owner = owner, repo = name, defaultBranch = repo.defaultBranch
125133
)
134+
} catch (_: RateLimitException) {
135+
rateLimited.set(true)
136+
null
126137
} catch (t: Throwable) {
127138
logger.warn("Failed to load latest release: ${t.message}")
128139
null
@@ -132,6 +143,9 @@ class DetailsViewModel(
132143
val statsDeferred = async {
133144
try {
134145
detailsRepository.getRepoStats(owner, name)
146+
} catch (_: RateLimitException) {
147+
rateLimited.set(true)
148+
null
135149
} catch (_: Throwable) {
136150
null
137151
}
@@ -144,6 +158,9 @@ class DetailsViewModel(
144158
repo = name,
145159
defaultBranch = repo.defaultBranch
146160
)
161+
} catch (_: RateLimitException) {
162+
rateLimited.set(true)
163+
null
147164
} catch (_: Throwable) {
148165
null
149166
}
@@ -152,6 +169,9 @@ class DetailsViewModel(
152169
val userProfileDeferred = async {
153170
try {
154171
detailsRepository.getUserProfile(owner)
172+
} catch (_: RateLimitException) {
173+
rateLimited.set(true)
174+
null
155175
} catch (t: Throwable) {
156176
logger.warn("Failed to load user profile: ${t.message}")
157177
null
@@ -177,6 +197,9 @@ class DetailsViewModel(
177197
} else {
178198
null
179199
}
200+
} catch (_: RateLimitException) {
201+
rateLimited.set(true)
202+
null
180203
} catch (t: Throwable) {
181204
logger.error("Failed to load installed app: ${t.message}")
182205
null
@@ -192,6 +215,11 @@ class DetailsViewModel(
192215
val userProfile = userProfileDeferred.await()
193216
val installedApp = installedAppDeferred.await()
194217

218+
if (rateLimited.get()) {
219+
_state.value = _state.value.copy(isLoading = false, errorMessage = null)
220+
return@launch
221+
}
222+
195223
val installable = latestRelease?.assets?.filter { asset ->
196224
installer.isAssetInstallable(asset.name)
197225
}.orEmpty()
@@ -221,6 +249,12 @@ class DetailsViewModel(
221249
isAppManagerEnabled = isAppManagerEnabled,
222250
installedApp = installedApp,
223251
)
252+
} catch (e: RateLimitException) {
253+
logger.error("Rate limited: ${e.message}")
254+
_state.value = _state.value.copy(
255+
isLoading = false,
256+
errorMessage = null
257+
)
224258
} catch (t: Throwable) {
225259
logger.error("Details load failed: ${t.message}")
226260
_state.value = _state.value.copy(

0 commit comments

Comments
 (0)