Skip to content

chore(reliability): recover() in fire-and-forget goroutines (closes #672)#859

Open
cristim wants to merge 3 commits into
mainfrom
fix/672-wave17
Open

chore(reliability): recover() in fire-and-forget goroutines (closes #672)#859
cristim wants to merge 3 commits into
mainfrom
fix/672-wave17

Conversation

@cristim

@cristim cristim commented May 30, 2026

Copy link
Copy Markdown
Member

Summary

Test plan

  • go build ./... passes
  • go test ./internal/auth/... ./internal/api/... -- 1852 tests pass
  • scheduler.go and app.go goroutines confirmed already protected (excluded from change)
  • cmd/multi_service.go signal-handler goroutine confirmed trivially safe (no code path that can panic)

Closes #672

Summary by CodeRabbit

  • Bug Fixes
    • Improved stability in several background tasks so unexpected errors no longer crash the app.
    • Token exchange, stale-item cleanup, API key usage tracking, and execution expiration are now more resilient to intermittent failures.
    • Added coverage for a recovery scenario to help prevent regressions.

@cristim cristim added triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm urgency/eventually No deadline impact/internal Team-internal only effort/xs Trivial / one-liner type/chore Maintenance / non-user-visible labels May 30, 2026
@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9258eae4-60fc-4ec0-920b-afdc8c9d2e54

📥 Commits

Reviewing files that changed from the base of the PR and between cc83047 and 7aceec7.

📒 Files selected for processing (5)
  • internal/api/db_rate_limiter.go
  • internal/api/handler_accounts.go
  • internal/api/handler_history.go
  • internal/auth/service_apikeys.go
  • internal/auth/service_apikeys_test.go
📝 Walkthrough

Walkthrough

Deferred panic-recovery blocks are added to five fire-and-forget goroutines: the rate-limiter cleanup ticker and async cleanup, GCP token exchange, stale execution expiry, and API key last-used update. Each recovers panics and logs via logging.Warnf. A regression test validates recovery behavior for the API key goroutine.

Changes

Goroutine Panic Recovery

Layer / File(s) Summary
Rate limiter cleanup recovery
internal/api/db_rate_limiter.go
Both the periodic ticker goroutine and the async cleanup goroutine wrap execution with deferred recover() and log recovered panics.
GCP token exchange recovery
internal/api/handler_accounts.go
The ts.Token() goroutine adds a deferred recover() that logs the panic and sends an error result through tokenChan.
Stale execution expiry recovery
internal/api/handler_history.go
expireStaleExecutionsAsync's goroutine adds a deferred recover() that logs a warning on panic.
API key last-used update recovery and test
internal/auth/service_apikeys.go, internal/auth/service_apikeys_test.go
The LastUsed update goroutine adds a deferred recover() with warning logging, and a new regression test verifies the goroutine's panic is recovered while ValidateUserAPIKey still returns correct data.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested labels: priority/p1, severity/high, urgency/this-sprint, effort/m

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the reliability fix adding recover() to fire-and-forget goroutines.
Linked Issues check ✅ Passed All three issue-listed goroutines now have deferred recover() guards and log panics with logging.Warnf.
Out of Scope Changes check ✅ Passed No clearly unrelated changes are present; the extra test and additional recover guard stay within the same reliability fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/672-wave17

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

@cristim

cristim commented May 30, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jun 4, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jun 5, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

Rebased onto feat/multicloud-web-frontend and resolved the merge conflict in internal/auth/service_apikeys.go.

The conflict was between two intents on the fire-and-forget UpdateLastUsed goroutine:

Resolution preserves both: the recover() deferred guard now wraps the entire goroutine body, including the lastUsedSFG.Do(...) singleflight call, so a panic raised inside the singleflight callback is still caught.

Verification:

  • go build ./...: pass
  • go vet ./internal/auth/... ./internal/api/...: pass
  • go test -race ./internal/auth/...: 551 pass
  • regression test TestValidateUserAPIKey_UpdateLastUsedPanicIsRecovered confirmed to FAIL pre-fix (panic crashes the test binary) and PASS post-fix, including under -race.
  • gofmt: clean on all changed Go files.

No open CodeRabbit review threads were outstanding.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

@cristim: The conflict resolution looks well-reasoned — placing defer recover() as the outermost deferred statement ensures it catches panics from anywhere inside the goroutine body, including within the singleflight.Do(...) callback. Both invariants (dedup + panic safety) are preserved. Triggering a full review now.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-06T07:04:28.235Z
Learning: In LeanerCloud/CUDly, PR `#907` (`feat(auth): group-membership-only authorization, remove roles, require >=1 group`) landed on `feat/multicloud-web-frontend` and removed `Session.Role` and `User.Role` entirely. Any `Principal` struct must use `GroupIDs []string` (or similar group-based field) instead of `Role string`. The `*auth.User` type no longer exposes `GetRole()`, `GetID()`, or `GetEmail()` accessors via interface assertion — using those in a type assertion causes a silent always-false fail-closed regression at runtime. The group-membership model also requires >=1 group for a valid user session; zero-groups users must be rejected at the authentication layer. Follow-up design issue is `#1010`.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-06T06:43:53.792Z
Learning: In LeanerCloud/CUDly PR `#995` (issue `#950` ownership gate), `POST /api/plans/{id}/purchases` (`createPurchaseExecutionsTx`) must stamp `created_by_user_id` via `resolveCreatorUserID(session)` at creation time. Without this, every scheduled purchase created via the Plans-page "Create planned purchases" action ships a NULL creator, which the ownership gate treats as a legacy row and denies management to all non-`update-any` users — including the creator themselves. The admin API key path must set `CreatedByUserID` to nil (not the key identity). Regressions: `TestHandler_createPlannedPurchases_StampsCreator` and `TestHandler_createPlannedPurchases_AdminAPIKeyCreatorIsNil`. Fixed in commit 9ef1b4352.
✅ Action performed

Full review finished.

@cristim

cristim commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

cristim added a commit that referenced this pull request Jun 9, 2026
…routine (#1111)

A panic in one region's worker (nil deref, SDK type assertion failure,
slice OOB) crashed the whole CLI process. Add a deferred recover() that
logs the panic with region context and lets the remaining goroutines
complete, mirroring the pattern applied to Lambda goroutines in PR #859
(issue #672).

Closes #997

Co-authored-by: Claude <noreply@anthropic.com>
@cristim
cristim changed the base branch from feat/multicloud-web-frontend to main June 9, 2026 15:43
@cristim

cristim commented Jun 19, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

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.

Caution

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

⚠️ Outside diff range comments (1)
internal/api/db_rate_limiter.go (1)

58-76: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Recover() at goroutine top silently kills the ticker loop forever.

The defer recover() wraps the whole for { select {...} } loop. If rl.cleanup() (line 72) panics, recovery logs it but the goroutine then returns, permanently stopping this periodic cleanup for the rest of the process lifetime — the ticker never fires again. Compare with the maybeCleanup goroutine below (lines 175-183), where defer rl.cleanupRunning.Store(false) correctly re-arms that path for future attempts after a panic.

Scope the recover to just the per-tick work instead, so a single panic doesn't disable the entire background worker.

🔧 Proposed fix
 func (rl *DBRateLimiter) StartCleanupWorker(ctx context.Context) {
 	go func() {
-		defer func() {
-			if r := recover(); r != nil {
-				logging.Warnf("db_rate_limiter: StartCleanupWorker goroutine panic: %v", r)
-			}
-		}()
 		ticker := time.NewTicker(dbRateLimiterScheduledCleanupInterval)
 		defer ticker.Stop()
 		for {
 			select {
 			case <-ctx.Done():
 				return
 			case <-ticker.C:
-				rl.cleanup()
+				func() {
+					defer func() {
+						if r := recover(); r != nil {
+							logging.Warnf("db_rate_limiter: StartCleanupWorker goroutine panic: %v", r)
+						}
+					}()
+					rl.cleanup()
+				}()
 			}
 		}
 	}()
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/db_rate_limiter.go` around lines 58 - 76, The panic recovery in
StartCleanupWorker currently wraps the entire ticker goroutine, so a panic
inside rl.cleanup() stops the cleanup loop permanently. Move the recover logic
to only guard the per-tick work inside the for/select branch in
StartCleanupWorker, keeping the goroutine alive after a single cleanup panic;
use the existing cleanup path in rl.cleanup() and mirror the resilience pattern
used by maybeCleanup so future ticks still run.
🧹 Nitpick comments (1)
internal/api/db_rate_limiter.go (1)

60-64: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider logging a stack trace with the recovered panic.

logging.Warnf(...%v", r) only captures the panic value, not where it happened. Since these are rare, hard-to-reproduce background panics, appending debug.Stack() would materially help post-mortem debugging. Same applies to the other recover blocks added in this PR (handler_accounts.go, handler_history.go, service_apikeys.go).

♻️ Example
+import "runtime/debug"
...
 defer func() {
   if r := recover(); r != nil {
-    logging.Warnf("db_rate_limiter: StartCleanupWorker goroutine panic: %v", r)
+    logging.Warnf("db_rate_limiter: StartCleanupWorker goroutine panic: %v\n%s", r, debug.Stack())
   }
 }()

Also applies to: 177-181

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/db_rate_limiter.go` around lines 60 - 64, The recover blocks in
db_rate_limiter.go and the other new goroutine recover paths in
handler_accounts.go, handler_history.go, and service_apikeys.go only log the
panic value, which loses the call site context. Update each panic recovery
helper/site (including StartCleanupWorker and the other affected functions) to
append a stack trace from debug.Stack() to the warning log, keeping the existing
panic value while adding the full stack for post-mortem debugging.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/api/db_rate_limiter.go`:
- Around line 58-76: The panic recovery in StartCleanupWorker currently wraps
the entire ticker goroutine, so a panic inside rl.cleanup() stops the cleanup
loop permanently. Move the recover logic to only guard the per-tick work inside
the for/select branch in StartCleanupWorker, keeping the goroutine alive after a
single cleanup panic; use the existing cleanup path in rl.cleanup() and mirror
the resilience pattern used by maybeCleanup so future ticks still run.

---

Nitpick comments:
In `@internal/api/db_rate_limiter.go`:
- Around line 60-64: The recover blocks in db_rate_limiter.go and the other new
goroutine recover paths in handler_accounts.go, handler_history.go, and
service_apikeys.go only log the panic value, which loses the call site context.
Update each panic recovery helper/site (including StartCleanupWorker and the
other affected functions) to append a stack trace from debug.Stack() to the
warning log, keeping the existing panic value while adding the full stack for
post-mortem debugging.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b6746adc-5503-4811-8963-506140106aa9

📥 Commits

Reviewing files that changed from the base of the PR and between 60526ce and cc83047.

📒 Files selected for processing (5)
  • internal/api/db_rate_limiter.go
  • internal/api/handler_accounts.go
  • internal/api/handler_history.go
  • internal/auth/service_apikeys.go
  • internal/auth/service_apikeys_test.go

@cristim

cristim commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

cristim added 3 commits July 17, 2026 21:26
)

Add deferred recover() to the three unprotected background goroutines
surfaced in the #669/#670 sibling audit:

- internal/auth/service_apikeys.go: UpdateLastUsed async goroutine
- internal/api/db_rate_limiter.go: cleanup async goroutine
- internal/api/handler_accounts.go: GCP ts.Token() goroutine; panic
  now also sends an error back on tokenChan so the caller returns
  promptly rather than blocking until the 15s deadline fires

Matches the existing pattern in ri_utilization_cache.go and scheduler.go.
…d (refs #672)

Add TestValidateUserAPIKey_UpdateLastUsedPanicIsRecovered to internal/auth
to prove the recover() in the UpdateLastUsed fire-and-forget goroutine works.

The test injects a panicking MockStore.UpdateAPIKeyLastUsed callback and uses
a done channel (closed by the mock Run callback before panicking) to wait for
goroutine execution without time.Sleep. Without the recover() the injected
panic crashes the test binary; with it the process survives and the test passes.
)

Add recover() to StartCleanupWorker (db_rate_limiter) and
expireStaleExecutionsAsync (handler_history), both fire-and-forget
goroutines that were absent from the original sweep.

Also use t.Cleanup for AssertExpectations in the panic-recovery test,
consistent with every other mock in the test file.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/xs Trivial / one-liner impact/internal Team-internal only priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/chore Maintenance / non-user-visible urgency/eventually No deadline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

chore(reliability): add recover() to non-fan-out fire-and-forget goroutines (auth, rate-limiter, GCP token-exchange)

1 participant