fix(api): bound unbounded queries + add Stripe transient-retry (prod-readiness) - #403
Conversation
…readiness) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nsient branches Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review: PR #403 (fix(api): bound unbounded queries + add Stripe transient-retry)
Recommendation: APPROVE
Summary
Small, well-scoped prod-readiness PR: caps HabitScheduleService.GetInstances forward horizon at a new AppConstants.MaxInstanceHorizonDays = 90 constant, and adds a bounded exponential-backoff StripeRetryPolicy wrapping every Stripe SDK call in StripeBillingService for transient (429/5xx/network/timeout) failures only. No DTO, endpoint, or contract-surface changes -- confirmed by diff inspection (only AppConstants.cs, HabitScheduleService.cs, the new StripeRetryPolicy.cs, StripeBillingService.cs, and two test files changed). Both changes are covered by new unit tests exercising the boundary conditions. No Critical or High findings.
Findings
Critical: None.
High: None.
Medium
[MEDIUM] Stripe Checkout/Portal session creation has no idempotency key
- dimension: Security (#12) -- defense-in-depth
- location: src/Orbit.Infrastructure/Services/StripeBillingService.cs:78, :93
- issue: CreateCheckoutSessionAsync and CreatePortalSessionAsync pass no RequestOptions.IdempotencyKey to Stripe, unlike CreateCustomerAsync (line 38) which does.
- risk: A client-side timeout followed by a caller-level retry (now more likely given the new retry wrapper) can create a duplicate Session object server-side. Low real-world impact -- Sessions are single-use, pre-charge redirect containers with no route exposing the orphaned duplicate to the user -- but it is inconsistent with the idempotency pattern already established for customer creation.
- fix: Pass a deterministic RequestOptions.IdempotencyKey (e.g. a key derived from userId + priceId) to CheckoutSessions.CreateAsync and PortalSessions.CreateAsync, consistent with CreateCustomerAsync.
- reference: orbit-api Stripe idempotency pattern, OWASP defense-in-depth
[MEDIUM] StripeRetryPolicy retries 429 without honoring Retry-After
- dimension: Security / Reliability (#12)
- location: src/Orbit.Infrastructure/Common/StripeRetryPolicy.cs:44-48
- issue: On a 429 (TooManyRequests) StripeException, the policy retries on a fixed exponential backoff (200ms/400ms) rather than reading the Retry-After header Stripe may return.
- risk: During a real rate-limit window this can retry sooner than Stripe wants, extending the throttle rather than backing off correctly. Bounded impact given only 2 retries total.
- fix: Read Retry-After from the StripeException/response headers when present and use it as the delay, falling back to the current exponential backoff otherwise.
- reference: Stripe API rate-limit guidance
Low / Info
- StripeBillingService.cs:187 -- a Stripe price ID is interpolated into a BillingProviderException message; confirmed this only surfaces server-side (never returned to the client -- CreateCheckoutCommand.cs maps it to a generic PaymentServiceUnavailable message first), so no data-exposure issue today.
- The horizon cap in HabitScheduleService.GetInstances is placed after the IsCompleted/IsFlexible early return and before any list allocation, so it never does unnecessary work. Clean placement.
Subagents
- security-reviewer: PASS (2 Medium, no Critical/High)
- contract-aligner: N/A -- no DTO, Controller route, or packages/shared type changed in this diff
Validation
- Build (dotnet): PASS (separate required CI check)
- Tests (dotnet): PASS (separate required CI check)
- SonarCloud Quality Gate: FAILURE -- 78.4% coverage on new code vs required >= 80% (informational; not a rubric Critical/High, does not change this recommendation, but this required check is currently red and will independently gate merge)
Deferred -- N/A dimensions and files not verdicted
- Dimensions 8 (DESIGN.md/AI-slop), 9 (Parity), 10 (i18n): N/A, diff touches only orbit-api src/tests, no apps/* UI files.
- Dimension 11 (Contract drift + backward-compat guard): N/A, no DTO/schema/endpoint changed; PR body confirms openapi.json unchanged, verified against the file list.
- Dimension 14 (FEATURES.md parity): N/A, purely internal hardening (query bound + retry policy), no user-facing feature/gating/platform change.
- Every changed file (AppConstants.cs, HabitScheduleService.cs, StripeRetryPolicy.cs, StripeBillingService.cs, HabitScheduleServiceTests.cs, StripeRetryPolicyTests.cs) received a verdict above -- nothing else deferred.
What is good
- The horizon cap is a single choke point (GetInstances) that both real callers (HabitScheduleFilters, GetCalendarMonthQuery) flow through, verified by grep -- no duplicated bound logic.
- StripeRetryPolicy cleanly separates transient (retry) from business (never retry) failures, mirrors the existing HttpRetryPolicy convention, and is tested for both the retry path and the non-retry path (card decline) plus exhaustion.
- Tests target the actual boundary conditions (exactly-90-days cap, within-horizon no-op, retry-then-succeed, exhaust-then-throw, non-transient no-retry) rather than just happy path.
- XML-doc comments added are compliant triple-slash summaries, not narration.
Recommendation
Approve as-is. The two Medium findings (idempotency key on Checkout/Portal sessions, Retry-After honoring) are defense-in-depth improvements worth a fast-follow, not blockers. Separately, note the SonarCloud coverage gate is currently failing (78.4% < 80%) -- a required check outside this rubric scope but will need attention before merge regardless of this review outcome.
|
There was a problem hiding this comment.
Code Review: PR #403 (fix(api): bound unbounded queries + add Stripe transient-retry)
Recommendation: REQUEST CHANGES
Summary
This PR bounds HabitScheduleService.GetInstances' forward horizon to AppConstants.MaxInstanceHorizonDays = 90 and adds a bounded, transient-only StripeRetryPolicy around every Stripe SDK call in StripeBillingService. Both changes are individually well-tested. However, the GetInstances cap introduces a real data-consistency gap on the same response DTO that the diff does not close, and a Medium finding on Stripe idempotency remains open.
Findings
[HIGH] Instances is now capped at 90 days but the sibling ScheduledDates field on the same DTO -- and the validator gating both -- is not, so a legal request silently returns inconsistent data
- dimension: Correctness / Validation
- location:
src/Orbit.Application/Habits/Services/HabitScheduleService.cs:418-419 - issue:
GetInstancesclampsdateTotodateFrom + 90days before building theInstanceslist. ButHabitScheduleItem/HabitScheduleChildItem(src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs:34,48and:67,77) carry a siblingScheduledDatesfield computed via the uncappedHabitScheduleService.GetScheduledDates(called with the samectx.DateFrom/ctx.DateToatHabitScheduleFilters.cs:77,211).GetHabitScheduleQueryValidator.cs:26-29still permitsdateTo - dateFromup toAppConstants.MaxRangeDays = 366for this endpoint -- the validator was not touched by this PR. - risk: For any validator-legal request with a range between 91 and 366 days, the response silently contains a full-range
ScheduledDatesarray but anInstancesarray truncated at day 90, with no error or truncation indicator. The PR body's own safety argument for this cap rests entirely on frontend clamping ("frontend clamps toMAX_RANGE_DAYS = 14"), which the project's own hard rule explicitly disallows as a substitute for backend validation (Orbit.Application/CLAUDE.md: "Frontend validation does NOT count"). This is the same failure mode the PR is nominally fixing (backend-unbounded query), just moved one field over. - fix: Either (a) tighten
GetHabitScheduleQueryValidatorto cap the range atMaxInstanceHorizonDaysfor this endpoint, (b) apply the same 90-day clamp insideGetScheduledDates/HabitScheduleFiltersso both fields are always consistent, or (c) return a clamped effective range so the two fields can't diverge. Add a regression test at theHabitScheduleFilters/handler level (the new tests only coverGetInstancesin isolation) assertingScheduledDatesandInstancescoverage stay consistent for a >90-day request. - reference:
Orbit.Application/CLAUDE.mdValidation section; orbit-api hard rule "Validation... The backend is the source of truth."
[MEDIUM] Stripe Checkout/Portal session creation still has no idempotency key (open from prior review)
- dimension: Security -- defense-in-depth
- location:
src/Orbit.Infrastructure/Services/StripeBillingService.cs:78-80(CreateCheckoutSessionAsync),:93-99(CreatePortalSessionAsync) - issue: Both calls are now wrapped in the new retry policy, which makes duplicate-object creation on transient-failure-then-retry more likely, but neither passes
RequestOptions.IdempotencyKey-- unlikeCreateCustomerAsync:38in the same file. - risk: Bounded (Sessions are single-use, no route exposes the duplicate), but avoidable and inconsistent with the pattern already established for customer creation. Flagged in this PR's prior review pass and still unaddressed.
- fix: Pass a deterministic
IdempotencyKey(e.g. derived fromuserId+priceId) toCheckoutSessions.CreateAsyncandPortalSessions.CreateAsync, mirroringCreateCustomerAsync.
What's good
StripeRetryPolicy correctly isolates transient failures (network/timeout/429/5xx) from business errors (card decline, invalid request, user cancellation) with solid boundary-condition test coverage. The GetInstances horizon cap is placed efficiently (before any list allocation) and is well-tested in isolation. No DTO/route/contract shape changed -- confirmed backward-compatible with existing clients.
Deferred / N/A
- Dimensions 8-10 (DESIGN.md/AI-slop, parity, i18n): N/A, backend-only diff, no
apps/*files touched. - Contract-aligner: N/A, no DTO/endpoint/shared-type change.
- dotnet build/test: skipped -- CI runs Build/Unit Tests/SonarCloud as separate required checks.
Not verifiable in CI
- Cross-repo consumer impact (
orbit-ui-mobile's actual request ranges for the schedule endpoint) -- the sibling repo is not checked out in this job; the High finding above is a backend contract gap independent of what the current mobile client happens to send today.
| return []; | ||
|
|
||
| if (dateTo.DayNumber - dateFrom.DayNumber > AppConstants.MaxInstanceHorizonDays) | ||
| dateTo = dateFrom.AddDays(AppConstants.MaxInstanceHorizonDays); |
There was a problem hiding this comment.
[HIGH] Correctness / Validation — this clamp bounds Instances to 90 days, but the sibling ScheduledDates field on the same HabitScheduleItem/HabitScheduleChildItem DTO is computed via the uncapped GetScheduledDates, and GetHabitScheduleQueryValidator still allows ranges up to AppConstants.MaxRangeDays = 366 for this endpoint (untouched by this PR). A validator-legal request spanning 91–366 days will silently return a full-range ScheduledDates array alongside an Instances array truncated at day 90, with no error or truncation flag.
The PR's stated safety justification for this cap relies on frontend clamping (MAX_RANGE_DAYS = 14), which Orbit.Application/CLAUDE.md explicitly rules out as a substitute for backend validation.
Fix: either cap the validator's range for this endpoint to MaxInstanceHorizonDays, apply the same clamp inside GetScheduledDates/HabitScheduleFilters so both fields stay consistent, or surface a clamped effective range. See full review for details.
…session idempotency (#403 follow-up) (#405) Post-merge re-review of #403 returned CHANGES_REQUESTED with two findings that landed on an orphaned branch and are now fixed-forward onto main. HIGH — GetHabitScheduleQuery returned a full-range ScheduledDates (uncapped GetScheduledDates, validator allows up to MaxRangeDays=366) alongside an Instances list capped at MaxInstanceHorizonDays=90, so a validator-legal 91-366-day request produced silently inconsistent fields. The query handler now clamps the effective dateTo to dateFrom + MaxInstanceHorizonDays before it flows into both FilterScheduledHabits (parent ScheduledDates) and the ScheduleMapContext (Instances + child dates), so both fields derive from the same 90-day window. Server-authoritative; no reliance on frontend clamping and no change to HabitScheduleService.cs. MEDIUM — CreateCheckoutSessionAsync / CreatePortalSessionAsync gained a deterministic RequestOptions.IdempotencyKey (mirroring CreateCustomerAsync) so the new transient-retry policy cannot duplicate a session. The checkout key includes the referral-coupon marker so a retry with a different discount body cannot collide (Stripe 409). Refs thomasluizon/orbit-ui-mobile#243, #403 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>



Bounds the last unbounded query-expansion path and adds transient-error resilience to the Stripe billing calls, closing the still-open prod-readiness Medium findings. All changes are internal upper bounds / retry wrappers — no DTO shape changes,
openapi.jsonunchanged, fully backward-compatible with old mobile clients.Findings addressed
2.152 — HabitSchedule
Instancesarray materialized a full 366-day rangeHabitScheduleService.GetInstancesnow caps its forward horizon to a new named constantAppConstants.MaxInstanceHorizonDays = 90(clampsdateTotodateFrom + 90before expanding). This is the single point both callers flow through (HabitScheduleFiltersfor the schedule query andGetCalendarMonthQuery), so both are bounded by one change.Verified no real caller depends on a larger expansion:
calendar-monthendpoint is validator-capped toMaxCalendarRangeDays = 62(≤ 90) — unaffected.MAX_RANGE_DAYS = 14(≤ 90) — unaffected.list_habitsMCP tool can request up toMaxRangeDays = 366but does not readInstances.The
Instanceslist field onHabitScheduleItem/HabitScheduleChildItemkeeps the same shape and type; only the worst-case element count shrinks.2.150 — StripeBillingService had no retry on transient errors
Added
Orbit.Infrastructure.Common.StripeRetryPolicy(mirrors the existingHttpRetryPolicypattern): a bounded exponential-backoff retry (2 retries, 200ms base) that retries only transient failures — connection/network errors and timeouts (which the Stripe SDK surfaces as rawHttpRequestException/OperationCanceledException) and429/5xxresponses (surfaced asStripeExceptioncarrying the status). Business errors (card declined402, invalid request400, auth401, etc.) and user cancellation are never retried and surface on the first attempt. Every Stripe SDK call inStripeBillingService(create customer, create checkout/portal session, get subscription, list invoices, get price, get coupon) is now wrapped; the existingcatch (StripeException) → BillingProviderExceptionwrapping is preserved.Already remediated (verified against current
main)The three query-cap findings from the report were already closed by prior PRs and re-verified here (server-side
.Take(<named constant>)+ unit tests already present) — no further change needed:IHabitLogReader.ReadRecentLogsAsynccaps atMaxHabitLogsReturned = 1000, newest-first (PR perf(api): bound habit-logs read with server-side ordering + row cap #347,HabitLogReaderTests).ISocialGraphReader.ReadVisibleFriendshipsAsynccaps atMaxFriends = 500(PR perf(api): push social query filtering + pagination into the DB #344,SocialGraphReaderTests.VisibleFriendships_CapAtLimit).ISocialGraphReader.ReadCheersPageAsynccaps atMaxCheersReturned = 200, newest-first (PR perf(api): push social query filtering + pagination into the DB #344,SocialGraphReaderTests.VisibleCheers_OrderNewestFirst_AndCapAtLimit).Tests
HabitScheduleServiceTests.GetInstances_RangeExceedingHorizon_CapsForwardWindow— a 200-day request returns exactlyMaxInstanceHorizonDays + 1instances, none past the horizon.HabitScheduleServiceTests.GetInstances_RangeWithinHorizon_ReturnsEveryScheduledDate— a 30-day request is unaffected (all 31 dates returned).StripeRetryPolicyTests— transientStripeException(503) and networkHttpRequestExceptionretry then succeed; card-declined (402) does not retry (1 attempt); persistent transient exhausts retries then throws;IsTransientstatus classification.Verification
dotnet build Orbit.slnx— 0 errors.dotnet test Orbit.slnx— all green.dotnet ef migrations has-pending-model-changes— false ("No changes have been made to the model since the last migration").src/Orbit.Api/openapi.json— unchanged (no contract/DTO change).Refs thomasluizon/orbit-ui-mobile#243 (prod-readiness remediation)
🤖 Generated with Claude Code