Skip to content

Add POST /v1/repo/{owner}/{name}/refresh for user-triggered refresh#7

Merged
rainxchzed merged 3 commits intomainfrom
repo-refresh-endpoint
May 4, 2026
Merged

Add POST /v1/repo/{owner}/{name}/refresh for user-triggered refresh#7
rainxchzed merged 3 commits intomainfrom
repo-refresh-endpoint

Conversation

@rainxchzed
Copy link
Copy Markdown
Member

@rainxchzed rainxchzed commented May 4, 2026

Summary

User-driven refresh button on the details screen. Refreshes one repo on demand: re-fetches from GitHub, upserts Postgres + Meili, returns the live data. Counterpart to the cache-first GET path.

Endpoint

POST /v1/repo/{owner}/{name}/refresh

  • Reads optional X-GitHub-Token (same as the GET).
  • Returns same shape as GET /v1/repo/{owner}/{name}.
  • Response carries Cache-Control: no-store -- never CDN-cached.
  • Sits inside the search rate-limit bucket (240/min/key).

POST chosen over GET-with-?refresh=true so Cloudflare doesn't cache the trigger and the state-changing nature is reflected in the verb.

Safety nets

Guard Limit Reason
Per-repo cooldown 30s per (owner, name) regardless of caller Stops one client (or coordinated set) from spam-clicking refresh on the same repo to torch pool tokens.
Global hourly budget 1000 refreshes/hour cumulative Caps total pool consumption from refresh traffic across all repos. ~5% of the 4-PAT aggregate quota.
Search bucket 240/min/key Already in place; the route was wired into it.
Quiet window refresh allowed User-driven, low traffic at 1-4 UTC; pool fallback still respects the quiet-window guarantee internally.

Cooldown stamp is recorded before the upstream call returns, so concurrent refreshes for the same repo see the stamp and back off rather than racing.

Outcomes -> HTTP status

Coordinator outcome HTTP Body
Ok (persisted) 200 full RepoResponse from DB
Ok (no usable release, metadata-only) 200 metadata-only RepoResponse from GitHub fetch
Cooldown 429 + Retry-After {"error":"cooldown","message":"Try again in Ns"}
BudgetExhausted 429 + Retry-After {"error":"budget_exhausted",...}
NotFound 404 {"error":"not_found"}
Archived 410 {"error":"archived"}
UpstreamError 502 {"error":"github_unreachable"}

Implementation notes

  • RepoRefreshCoordinator (in ingest/) is a thin gate-and-dispatch layer. It accepts refreshUpstream + persistFn as lambdas instead of a direct GitHubSearchClient reference so unit tests don't need a real Ktor HttpClient. AppModule wires both to searchClient::refreshRepo and searchClient::persist.
  • Coordinator + route are internal because GitHubSearchClient.RefreshResult and RepoWithRelease are internal data classes. All callers live in the same module.
  • The route reuses the metadata-only path's toMetadataOnlyResponse (now internal instead of private) for the no-usable-release case.
  • Cooldown map prunes itself opportunistically on each successful refresh (entries older than 1h dropped). No background sweeper needed.
  • Budget window rotates via CAS so a parallel rotation doesn't drop a fresh window mid-flight.

What this does NOT do

  • Does NOT auto-refresh on detail-page open. That's SWR; deferred per the architect review (would amplify pool consumption per-page-open without per-user demand evidence).
  • Does NOT re-rank categories / trending / topics. Those are batch-derived from the worker; per-repo refresh doesn't move them.
  • Does NOT purge Cloudflare's cache for the GET URL. Acceptable: client renders the POST response directly; the GET cache catches up via TTL (5 min).

Test plan

  • ./gradlew test -- 9 new coordinator tests + existing suites all green.
  • After deploy, curl -X POST https://api.github-store.org/v1/repo/sindresorhus/refined-github/refresh -- expect 200 + fresh data.
  • Repeat immediately -- expect 429 + Retry-After.
  • Wait 30s, retry -- expect 200 again.

Summary by CodeRabbit

  • New Features

    • POST /v1/repo/{owner}/{name}/refresh to trigger a repo metadata + latest-release refresh; enforces per-repo 30s cooldown and a global hourly budget (1000).
    • Successful responses mirror the GET shape; successful responses include Cache-Control: no-store; 429 uses Retry-After.
  • Documentation

    • Client spec and UX guidance for refresh button, header usage, response contracts, and rate-limit behaviors.
  • Tests

    • Added comprehensive tests for cooldowns, budget handling, outcome mappings, and concurrency.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 4, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0b2ad3eb-0d5d-4785-ac5e-f0d237463c13

📥 Commits

Reviewing files that changed from the base of the PR and between b42eff4 and a31dd38.

📒 Files selected for processing (3)
  • docs/client/repo-refresh.md
  • src/main/kotlin/zed/rainxch/githubstore/ingest/RepoRefreshCoordinator.kt
  • src/test/kotlin/zed/rainxch/githubstore/ingest/RepoRefreshCoordinatorTest.kt

📝 Walkthrough

Walkthrough

Adds POST /v1/repo/{owner}/{name}/refresh plus a RepoRefreshCoordinator enforcing per-repo 30s cooldowns and a rolling 1000/hour budget; coordinator calls GitHubSearchClient.refreshRepo, persists via provided persist function, and routes return refreshed repo data with Cache-Control: no-store or mapped error statuses.

Changes

Repository Refresh Feature

Layer / File(s) Summary
Outcome Model
src/main/kotlin/zed/rainxch/githubstore/ingest/RepoRefreshCoordinator.kt
Adds sealed Outcome hierarchy: Ok(repo, metadataPersisted), Cooldown(retryAfterSeconds), BudgetExhausted(retryAfterSeconds), NotFound, Archived, UpstreamError.
Core Coordinator Logic
src/main/kotlin/zed/rainxch/githubstore/ingest/RepoRefreshCoordinator.kt
Implements RepoRefreshCoordinator.refresh(owner,name,userToken): lowercase key, atomic per-repo cooldown via ConcurrentHashMap.compute, rolling hourly budget with atomic counter and window rotation, opportunistic pruning of stale entries, upstream call mapping to Outcome, and budgetUsed() helper.
Dependency Injection
src/main/kotlin/zed/rainxch/githubstore/AppModule.kt
Registers RepoRefreshCoordinator as a Koin single, wiring GitHubSearchClient::refreshRepo as refreshUpstream and GitHubSearchClient::persist as persistFn.
Route Handler
src/main/kotlin/zed/rainxch/githubstore/routes/RepoRefreshRoutes.kt
Adds repoRefreshRoutes POST /repo/{owner}/{name}/refresh: validates identifiers, reads optional X-GitHub-Token, delegates to coordinator, maps Outcome to HTTP responses (200 with persisted or metadata-only repo + Cache-Control: no-store, 429 with Retry-After for cooldown/budget, 404/410/502/400).
Response Utility Export
src/main/kotlin/zed/rainxch/githubstore/routes/RepoRoutes.kt
Changes GitHubRepo.toMetadataOnlyResponse() visibility from private to internal so refresh route can reuse the same response shape.
Routing Integration
src/main/kotlin/zed/rainxch/githubstore/routes/Routing.kt
Injects RepoRefreshCoordinator in configureRouting() and registers repoRefreshRoutes(...) under /v1 inside the rateLimit("search") block.
Tests
src/test/kotlin/zed/rainxch/githubstore/ingest/RepoRefreshCoordinatorTest.kt
Adds comprehensive tests: success+persist, cooldown behaviour (per-repo, case-insensitive), hourly budget exhaustion, upstream status mappings (Gone→NotFound, Archived→Archived, TransientFailure→UpstreamError), NoUsableRelease→Ok without persist, concurrency test ensuring single upstream call, and budget vs cooldown interplay.
Documentation
docs/client/repo-refresh.md, CLAUDE.md
Adds client-facing spec and brief doc entry describing request contract, responses, error handling, rate-limit hierarchy, UX guidance, and backend authoritative files.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Route as RepoRefreshRoute
    participant Coord as RepoRefreshCoordinator
    participant GitHub as GitHubSearchClient
    participant DB as RepoRepository

    Client->>Route: POST /v1/repo/{owner}/{name}/refresh\n(optional X-GitHub-Token)
    Route->>Route: validate owner/name\nextract token
    Route->>Coord: refresh(owner, name, userToken)
    Coord->>Coord: check per-repo cooldown
    Coord->>Coord: rotate/check hourly budget
    alt Cooldown active
        Coord-->>Route: Outcome.Cooldown(retryAfter)
    else Budget exhausted
        Coord-->>Route: Outcome.BudgetExhausted(retryAfter)
    else Allowed
        Coord->>GitHub: refreshRepo(fullName, userToken)
        GitHub-->>Coord: RefreshResult
        alt Success with release
            Coord->>DB: persist(RepoWithRelease)
            DB-->>Coord: persisted
            Coord-->>Route: Outcome.Ok(repo, metadataPersisted=true)
        else Success without release
            Coord-->>Route: Outcome.Ok(repo, metadataPersisted=false)
        else NotFound / Archived / Error
            Coord-->>Route: Outcome.NotFound / Archived / UpstreamError
        end
    end
    Route->>Client: 200 + repo data + Cache-Control: no-store\nor mapped error status (429/404/410/502/etc.)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 I hopped to the repo, gave it a nudge,

Thirty seconds to rest, but I won't begrudge.
A thousand an hour, a tidy little quota,
I fetched, I persisted — then danced in a rota.
Fresh data! Fresh hops! 🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.53% 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 accurately describes the main change: adding a new POST endpoint for user-triggered repository refresh. It is concise, specific, and reflects the primary objective of the changeset.
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 repo-refresh-endpoint

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: 0/1 reviews remaining, refill in 60 minutes.

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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/kotlin/zed/rainxch/githubstore/ingest/RepoRefreshCoordinator.kt`:
- Around line 63-86: The cooldown check in refresh is subject to a TOCTOU race
because lastRefreshAt is read and then later updated; change the logic to use
lastRefreshAt.compute(key) (or computeIfAbsent/compute) to atomically inspect
the existing Instant and, if allowed, set the new now timestamp inside the same
compute call so concurrent coroutines cannot both pass the cooldown gate; ensure
the compute returns the stored Instant for future calls, and adjust flow so the
timestamp claim happens before the budget gate (not after) so that a
budget-exhausted request still occupies the cooldown slot.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2566ff2d-0b0c-4a8e-8bdf-5349c4b26626

📥 Commits

Reviewing files that changed from the base of the PR and between e205295 and b42eff4.

📒 Files selected for processing (7)
  • CLAUDE.md
  • src/main/kotlin/zed/rainxch/githubstore/AppModule.kt
  • src/main/kotlin/zed/rainxch/githubstore/ingest/RepoRefreshCoordinator.kt
  • src/main/kotlin/zed/rainxch/githubstore/routes/RepoRefreshRoutes.kt
  • src/main/kotlin/zed/rainxch/githubstore/routes/RepoRoutes.kt
  • src/main/kotlin/zed/rainxch/githubstore/routes/Routing.kt
  • src/test/kotlin/zed/rainxch/githubstore/ingest/RepoRefreshCoordinatorTest.kt

Comment thread src/main/kotlin/zed/rainxch/githubstore/ingest/RepoRefreshCoordinator.kt Outdated
@rainxchzed rainxchzed merged commit 82f785a into main May 4, 2026
1 of 2 checks passed
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