Skip to content

Route anon GitHub reads through backend pool#505

Merged
rainxchzed merged 4 commits into
mainfrom
feat/route-anon-traffic-through-backend
May 4, 2026
Merged

Route anon GitHub reads through backend pool#505
rainxchzed merged 4 commits into
mainfrom
feat/route-anon-traffic-through-backend

Conversation

@rainxchzed
Copy link
Copy Markdown
Member

@rainxchzed rainxchzed commented May 4, 2026

Anon users were hitting GitHub's 60/hr direct limit because several feature repos called `api.github.com` directly instead of going through the backend's 4-token pool (20k/hr aggregate). This PR rewires those paths through `BackendApiClient` so anon traffic is amplified by the pool.

Builds on backend PR #4 which raised the per-IP search bucket 60→240/min and added `/v1/users/{u}/repos` + `/v1/users/{u}/starred` passthrough.

Changes

Backend client (`BackendApiClient`):

  • New methods: `getUserRepos(username, page, perPage, sort, type)`, `getUserStarred(username, page, perPage)`.
  • All read methods now parse `Retry-After` + `X-RateLimit-Reset` headers on 429 → `RateLimitedException(retryAfterSeconds, resetEpochSeconds)`.

Shared fallback policy (`BackendFallbackPolicy.kt`):

  • Extracted `shouldFallbackToGithubOrRethrow` from `DetailsRepositoryImpl` into a top-level helper. Same semantics: rethrows `CancellationException`, falls back on infra/5xx, doesn't fall back on 4xx.
  • Backend `RateLimitedException` is converted to the domain `RateLimitException` and rethrown so callers (Tweaks, Details, Apps) can show the existing rate-limit toast with the backend's `Retry-After` baked into `RateLimitInfo.resetTimestamp`.

`AppsRepositoryImpl`:

  • `fetchRepoInfo` and `getLatestRelease` now try `backendApiClient.getRepo` / `getReleases` first, fall back to direct GitHub on 5xx/infra errors. Kills the rate-limit prompt when adding apps by link or scanning starred repos for APK releases.

`StarredRepositoryImpl.checkForValidAssets`:

  • Per-repo APK probe routes through `backendApiClient.getReleases`. Add-from-starred picker scans no longer drain anon's 60/hr budget.

`DeveloperProfileRepositoryImpl`:

  • `getDeveloperProfile` (`/users/{u}`) → `backendApiClient.getUser` (existing endpoint).
  • `getDeveloperRepositories` (`/users/{u}/repos`) → `backendApiClient.getUserRepos` (new endpoint from backend PR I can't login in the app #4).
  • `checkReleaseInfo` (`/repos/{o}/{r}/releases`) → `backendApiClient.getReleases`.
  • All three keep the direct path as fallback for backend infra failures.

DTO compatibility:

  • `UserProfileNetwork` extended with `email`, `publicGists`, `createdAt`, `updatedAt` (all optional) so backend's `/v1/user` passthrough deserializes the same surface dev-profile screen needs.
  • `GithubRepoNetworkModel` extended with `archived`, `openIssuesCount`, `pushedAt`, `hasDownloads` (all optional) for the same reason on `/v1/users/{u}/repos`.

Tradeoffs

  • Backend `RateLimitedException` is reflected up as the domain `RateLimitException` rather than introducing a third class. Existing UI catch sites (8+ in DetailsViewModel, 1 in StarredPickerViewModel) keep working unchanged. The trade is that `RateLimitInfo.limit` is set to 0 for backend-origin errors since we don't know the backend's per-IP bucket from a 429 alone — only `resetTimestamp` is meaningful. Future improvement: surface `X-RateLimit-Limit` / `X-RateLimit-Remaining` from backend.
  • `/user/starred` (signed-in user's own starred list) still goes direct because it's OAuth-bound — backend has no equivalent for this auth flow. Signed-in users hit 5000/hr GitHub anyway, so the only impact is on signed-in-anon edge cases that don't really exist.
  • DetailsRepository's secondary GitHub enrichment call (`openIssues`/`license`) still goes direct. Cached value falls through gracefully on rate-limit. Future: ask backend to include those fields in `/v1/repo` so this last call can drop.
  • Did not bump UI strings to surface `Retry-After` countdown. The plumbing (`RateLimitInfo.resetTimestamp`) is in place; UX polish can land separately.

Test plan

  • Sign out. Open Apps → Add by link → paste `https://github.com/foo/bar\`. Verify no rate-limit prompt after 5+ adds in a minute.
  • Sign in, open Add from starred picker on an account with 100+ stars. Verify scan completes without 429 (backend pool absorbs).
  • Open Developer Profile screen for a user with 50+ public repos. Verify all repos load + per-repo release-probe completes.
  • Backend deliberately rate-limited (force 429 via curl loop, hit anon bucket): rate-limit toast appears in app with `RateLimitInfo.resetTimestamp` populated.
  • Backend offline (5xx) → direct-GitHub fallback fires; same UX as before this PR.

Summary by CodeRabbit

  • New Features

    • Enhanced repository metadata now includes archived status, open issues count, and last push timestamp
    • Expanded user profile information with email, public gists count, and account creation/update timestamps
    • Integrated backend API with automatic GitHub fallback for improved service reliability
  • Bug Fixes

    • Improved rate limit handling with server-provided reset timing for better retry accuracy

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 4, 2026

Walkthrough

The PR integrates backend API client support across multiple feature modules with fallback to GitHub. It extends network DTOs with new fields, implements enhanced rate-limit tracking, establishes a centralized fallback policy, and updates repositories (StarredRepositoryImpl, AppsRepositoryImpl, DeveloperProfileRepositoryImpl) to query the backend first, falling back to GitHub on non-retriable errors.

Changes

Backend Integration with Fallback Pattern

Layer / File(s) Summary
Data Model Extensions
core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubRepoNetworkModel.kt, core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/UserProfileNetwork.kt
Added fields to DTOs: archived, openIssuesCount, pushedAt, hasDownloads to GithubRepoNetworkModel; email, publicGists, createdAt, updatedAt to UserProfileNetwork with corresponding @SerialName mappings.
Network Error Handling
core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt
RateLimitedException now captures retryAfterSeconds and resetEpochSeconds from response headers. All fetch methods parse HTTP 429 via new private buildRateLimited(response) helper instead of generic parameterless exception.
Fallback Policy
core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendFallbackPolicy.kt
New public shouldFallbackToGithubOrRethrow(cause: Throwable) centralizes backend-to-GitHub fallback decisions: rethrows CancellationException, converts RateLimitedException to domain RateLimitException, allows fallback only on 5xx BackendException, and rethrows others.
DTO Mappers
feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/mappers/GithubRepoNetworkModelToGitHubRepoResponse.kt, feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/mappers/UserProfileNetworkToDomain.kt
New extension mappers: GithubRepoNetworkModel.toGitHubRepoResponse() and UserProfileNetwork.toDeveloperProfile() transform network DTOs to domain models with null-safe defaults.
DI Wiring
core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt, feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/di/SharedModule.kt, feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/di/SharedModule.kt
Koin bindings updated to inject backendApiClient into StarredRepositoryImpl, AppsRepositoryImpl, and DeveloperProfileRepositoryImpl constructors.
Repository Implementation
core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/StarredRepositoryImpl.kt, feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt, feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt
Repositories now call backend client first (user profile, repos, releases); on non-fallback backend errors return early; on fallback-eligible errors continue to GitHub HTTP paths. AppsRepositoryImpl adds resolveLatestTagViaReleases helper for backend-first tag resolution. StarredRepositoryImpl refactors checkForValidAssets to use backendApiClient.getReleases with matchesPlatform helper.

Sequence Diagram

sequenceDiagram
    participant Client
    participant BackendAPI as Backend API
    participant FallbackPolicy as Fallback Policy
    participant GitHubAPI as GitHub API
    
    Client->>BackendAPI: fetch repo/user/releases
    BackendAPI-->>Client: response or error
    
    alt Success Response
        Client->>Client: map DTO to domain
        Note over Client: Return result
    else HTTP 429 (Rate Limited)
        Client->>Client: parse Retry-After<br/>& Reset headers
        Client->>Client: throw RateLimitedException<br/>(with timing)
        Client->>FallbackPolicy: shouldFallbackToGithubOrRethrow()
        alt Rate Limit (non-fallback)
            FallbackPolicy-->>Client: throw domain RateLimitException
        end
    else HTTP 5xx (Server Error)
        Client->>FallbackPolicy: shouldFallbackToGithubOrRethrow()
        FallbackPolicy-->>Client: return true (allow fallback)
        Client->>GitHubAPI: fetch from GitHub
        GitHubAPI-->>Client: response
        Client->>Client: map to domain
        Note over Client: Return GitHub result
    else Other Error
        Client->>FallbackPolicy: shouldFallbackToGithubOrRethrow()
        FallbackPolicy-->>Client: throw exception
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 The backend hops first, swift and sure,
Then GitHub stands as a backup door,
Rate limits tick with seconds precise,
Fallback rules keep the system nice,
Repos now rest on a dual-layer tale!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Route anon GitHub reads through backend pool' accurately summarizes the main objective: redirecting anonymous GitHub API calls through the backend's token pool instead of direct api.github.com calls.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/route-anon-traffic-through-backend

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt (1)

98-119: ⚠️ Potential issue | 🔴 Critical

Rethrow coroutine cancellation in these fallback paths.

Lines 98, 161, and 211 currently fall through to catch (e: Exception), which also catches CancellationException. That turns a cancelled coroutine into null and lets the call keep going after its parent scope has been cancelled.

All three functions (getLatestRelease, fetchRepoInfo, and resolveLatestTagViaReleases) are suspend functions where this matters.

Suggested fix
+import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.first
 ...
         } catch (e: RateLimitException) {
             throw e
+        } catch (e: CancellationException) {
+            throw e
         } catch (e: Exception) {
             logger.error("Failed to fetch latest release for $owner/$repo: ${e.message}")
             null
         }
 ...
         } catch (e: RateLimitException) {
             throw e
+        } catch (e: CancellationException) {
+            throw e
         } catch (e: Exception) {
             logger.error("Failed to fetch repo info for $owner/$repo: ${e.message}")
             null
         }
 ...
+        } catch (e: CancellationException) {
+            throw e
         } catch (_: Exception) {
             null
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt`
around lines 98 - 119, The generic Exception catch blocks in suspend functions
getLatestRelease, fetchRepoInfo, and resolveLatestTagViaReleases swallow
CancellationException; modify each function to rethrow coroutine cancellations
by adding an early check to if (e is CancellationException) throw e before
handling/logging other exceptions so that cancellations propagate correctly
(i.e., in each catch (e: Exception) block, rethrow when e is
CancellationException, then perform logger.error and return the fallback/null as
before).
🧹 Nitpick comments (1)
core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendFallbackPolicy.kt (1)

6-6: 💤 Low value

Prefer kotlinx.datetime.Clock for consistency with the rest of the codebase.

kotlin.time.Clock is stable stdlib and produces the correct epochSeconds value, but the project's convention uses kotlinx.datetime.Clock (e.g., RateLimitInfo.kt). In kotlinx-datetime 0.6+ the two are interchangeable (kotlinx.datetime.Instant is a typealias for kotlin.time.Instant), so this is purely a consistency issue.

♻️ Proposed change
-import kotlin.time.Clock
+import kotlinx.datetime.Clock

Clock.System.now().epochSeconds usage on line 23 stays unchanged.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendFallbackPolicy.kt`
at line 6, Replace the stdlib Clock import with the kotlinx.datetime variant to
match project conventions: change import kotlin.time.Clock to import
kotlinx.datetime.Clock and keep using Clock.System.now().epochSeconds in
BackendFallbackPolicy (the Clock.System.now().epochSeconds usage should remain
unchanged).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt`:
- Around line 196-215: The backend-path currently returns stableRelease.tagName
even when no installable asset exists, causing inconsistency with the GitHub
fallback; update the backend success branch (inside the backendResult.fold
onSuccess lambda in DeveloperProfileRepositoryImpl) to only return the release
tag when hasInstallable is true (e.g., return Triple(true, hasInstallable, if
(hasInstallable) stableRelease.tagName else null)) so latestVersion is null for
unsupported platforms just like the GitHub branch.

---

Outside diff comments:
In
`@feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt`:
- Around line 98-119: The generic Exception catch blocks in suspend functions
getLatestRelease, fetchRepoInfo, and resolveLatestTagViaReleases swallow
CancellationException; modify each function to rethrow coroutine cancellations
by adding an early check to if (e is CancellationException) throw e before
handling/logging other exceptions so that cancellations propagate correctly
(i.e., in each catch (e: Exception) block, rethrow when e is
CancellationException, then perform logger.error and return the fallback/null as
before).

---

Nitpick comments:
In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendFallbackPolicy.kt`:
- Line 6: Replace the stdlib Clock import with the kotlinx.datetime variant to
match project conventions: change import kotlin.time.Clock to import
kotlinx.datetime.Clock and keep using Clock.System.now().epochSeconds in
BackendFallbackPolicy (the Clock.System.now().epochSeconds usage should remain
unchanged).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1e2afb19-31de-4eda-8bd5-348e94f8a90c

📥 Commits

Reviewing files that changed from the base of the PR and between 15abf03 and 50f4e5a.

📒 Files selected for processing (12)
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubRepoNetworkModel.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/UserProfileNetwork.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendFallbackPolicy.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/StarredRepositoryImpl.kt
  • feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/di/SharedModule.kt
  • feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt
  • feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/di/SharedModule.kt
  • feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/mappers/GithubRepoNetworkModelToGitHubRepoResponse.kt
  • feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/mappers/UserProfileNetworkToDomain.kt
  • feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt

Comment on lines +196 to +215
val backendResult = backendApiClient.getReleases(owner, repoName, perPage = 10)
backendResult.fold(
onSuccess = { releases ->
val stableRelease = releases.firstOrNull { it.draft != true && it.prerelease != true }
if (stableRelease == null) return Triple(releases.isNotEmpty(), false, null)
val hasInstallable = stableRelease.assets.any { asset ->
val name = asset.name.lowercase()
when (platform) {
Platform.ANDROID -> name.endsWith(".apk")
Platform.WINDOWS -> name.endsWith(".msi") || name.endsWith(".exe")
Platform.MACOS -> name.endsWith(".dmg") || name.endsWith(".pkg")
Platform.LINUX -> name.endsWith(".appimage") || name.endsWith(".deb") ||
name.endsWith(".rpm") || name.endsWith(".pkg.tar.zst")
}
}
return Triple(true, hasInstallable, stableRelease.tagName)
},
onFailure = { error ->
if (!shouldFallbackToGithubOrRethrow(error)) return Triple(false, false, null)
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep latestVersion consistent with the GitHub fallback path.

Line 211 returns stableRelease.tagName even when no installable asset exists, but the direct GitHub branch at Line 265 only exposes a version when hasInstallableAssets is true. Backend-first now changes what the profile can show for unsupported platforms.

Suggested fix
-                return Triple(true, hasInstallable, stableRelease.tagName)
+                return Triple(
+                    true,
+                    hasInstallable,
+                    if (hasInstallable) stableRelease.tagName else null,
+                )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt`
around lines 196 - 215, The backend-path currently returns stableRelease.tagName
even when no installable asset exists, causing inconsistency with the GitHub
fallback; update the backend success branch (inside the backendResult.fold
onSuccess lambda in DeveloperProfileRepositoryImpl) to only return the release
tag when hasInstallable is true (e.g., return Triple(true, hasInstallable, if
(hasInstallable) stableRelease.tagName else null)) so latestVersion is null for
unsupported platforms just like the GitHub branch.

@rainxchzed rainxchzed merged commit 1280a59 into main May 4, 2026
1 check passed
@rainxchzed rainxchzed deleted the feat/route-anon-traffic-through-backend branch May 4, 2026 12:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant