Skip to content

fix(api/plans): differentiate ErrNotFound->404 + add structured slog on createPlan errors (closes #944) - #946

Merged
cristim merged 2 commits into
feat/multicloud-web-frontendfrom
fix/create-plan-500-observability
Jun 5, 2026
Merged

cristim merged 2 commits into
feat/multicloud-web-frontendfrom
fix/create-plan-500-observability

Conversation

@cristim

@cristim cristim commented Jun 4, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Adds ErrNotFound->404 mapping + logging.Errorf context at all three DB call sites in createPlan (was returning opaque 500 on every error)
  • Adds validateTargetAccounts + rollbackPlan + SetPlanAccounts wiring so createPlan is on parity with the setPlanAccounts endpoint (which already maps ErrNotFound->404)
  • Adds TargetAccounts field to PlanRequest and enforces non-empty with UUID validation, preventing "universal plan" rows

Fixes #944.

Test plan

  • TestHandler_createPlan -- updated to supply target_accounts and assert SetPlanAccounts was called
  • TestHandler_createPlan_RejectsEmptyTargetAccounts -- missing/empty target_accounts returns 400, CreatePurchasePlan never called
  • TestHandler_createPlan_UnknownAccountReturns404 -- account UUID not in store returns 404 (not 500); regression guard for the inconsistency
  • TestHandler_createPlan_DBErrorOnCreateReturns500WithLog -- DB error on CreatePurchasePlan returns structured 500 ClientError; raw error detail does not leak to caller
  • TestHandler_HandleRequest_CreatePlan -- updated to pass target_accounts through the full router path
  • All 4749 tests pass: go test ./... -timeout 120s

Summary by CodeRabbit

  • Bug Fixes

    • Improved error handling for plan creation operations with appropriate HTTP status codes (404 for missing accounts, 500 for server errors).
    • Ensured sensitive error details are not exposed to users.
  • Tests

    • Added regression tests to verify correct error responses for missing accounts and database failures.
    • Updated existing test to validate error behavior during rollback scenarios.

@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-sprint Within the current sprint impact/all-users Affects every user effort/s Hours type/bug Defect labels Jun 4, 2026
@coderabbitai

coderabbitai Bot commented Jun 4, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 476ec396-7bf8-4a2a-98ef-6ddbd9104c48

📥 Commits

Reviewing files that changed from the base of the PR and between f035285 and 1bfa4a9.

📒 Files selected for processing (4)
  • internal/api/handler_plans.go
  • internal/api/handler_plans_test.go
  • internal/api/handler_test.go
  • internal/api/types.go

📝 Walkthrough

Walkthrough

The PR standardizes HTTP error classification in the createPlan endpoint to return 404 (not found) for missing accounts and 500 (internal error) for DB failures, instead of treating all storage errors as opaque 500s. A new error-mapping helper is introduced, applied to the two failing call sites, and validated with new regression tests.

Changes

Error handling standardization in createPlan

Layer / File(s) Summary
Error mapping helper and imports
internal/api/handler_plans.go
Adds errors package import and mapCreatePlanStorageError helper that classifies storage errors: config.ErrNotFound → 404 ClientError with supplied message, other errors → 500 ClientError with generic message and structured ERROR log.
Apply error mapping in createPlan
internal/api/handler_plans.go
Updates CreatePurchasePlan and SetPlanAccounts error handling in createPlan to use the new error mapper instead of returning raw errors or wrapping with fmt.Errorf, ensuring proper HTTP status codes and context-aware logging.
Regression tests for error classification
internal/api/handler_plans_test.go
Adds TestHandler_createPlan_UnknownAccountReturns404 (verifies missing account UUID returns 404) and TestHandler_createPlan_DBErrorOnCreateReturns500WithLog (verifies DB errors return 500 without exposing raw error text); updates existing rollback test to assert safe error wrapping.
Test fixtures and doc updates
internal/api/handler_test.go, internal/api/types.go
Parameterizes hardcoded target_accounts UUID in TestHandler_HandleRequest_CreatePlan and fixes PlanRequest.TargetAccounts doc comment punctuation.

Sequence Diagram

sequenceDiagram
  participant Client
  participant createPlan
  participant CreatePurchasePlan
  participant SetPlanAccounts
  participant mapCreatePlanStorageError
  participant Logger
  
  Client->>createPlan: POST /api/plans
  createPlan->>CreatePurchasePlan: config.CreatePurchasePlan(ctx, plan)
  CreatePurchasePlan-->>createPlan: error (DB failure or ErrNotFound)
  createPlan->>mapCreatePlanStorageError: err
  alt ErrNotFound
    mapCreatePlanStorageError-->>createPlan: 404 ClientError
    createPlan-->>Client: 404 response
  else Other error
    mapCreatePlanStorageError->>Logger: ERROR with context
    mapCreatePlanStorageError-->>createPlan: 500 ClientError
    createPlan-->>Client: 500 response
  end
  
  Client->>createPlan: POST /api/plans (valid)
  createPlan->>SetPlanAccounts: h.config.SetPlanAccounts(ctx, plan.ID, accounts)
  SetPlanAccounts-->>createPlan: error (account not found or DB)
  createPlan->>mapCreatePlanStorageError: err
  mapCreatePlanStorageError-->>createPlan: 404 or 500 ClientError
  createPlan-->>Client: 404 or 500 response
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

  • LeanerCloud/CUDly#743: Introduces the target_accounts-driven account assignment and rollback mechanics that this PR wraps with standardized error handling and safe error-leakage prevention.

Poem

🐰 A rabbit hops through plans with care,
No more opaque 500s laid bare—
404 shines when accounts vanish,
Logs now speak truth, no need to banish,
Error maps turn chaos into light! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% 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 PR title accurately summarizes the main change: mapping ErrNotFound to HTTP 404 and adding structured logging for createPlan errors.
Linked Issues check ✅ Passed All requirements from issue #944 are met: ErrNotFound maps to 404 [944], structured logging added at call sites [944], regression test for unknown account returns 404 [944], and parity with updatePlan/setPlanAccounts achieved [944].
Out of Scope Changes check ✅ Passed All changes are in-scope: the mapCreatePlanStorageError helper, error handling improvements, test additions/updates, and minor formatting align directly with issue #944 objectives.

✏️ 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 fix/create-plan-500-observability

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

@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.

…n createPlan errors (closes #944)

Three call sites in createPlan returned raw/wrapped plain errors that the
router defaulted to HTTP 500:

1. CreatePurchasePlan: now maps ErrNotFound->404; any other DB error logs
   provider/service/account-count context via logging.Errorf and returns a
   well-formed 500 ClientError instead of leaking internal details.

2. validatePlanAccountProviders (inner GetCloudAccount path): already
   returns ClientError(404) for nil accounts; the function is now called
   from createPlan with a rollback guard so partial plan rows are cleaned up
   on validation failure.

3. SetPlanAccounts: maps ErrNotFound->404; transient DB errors log plan-ID
   and account-count then return 500 ClientError.

Also adds:
- validateTargetAccounts: requires non-empty target_accounts with valid UUIDs
  (rejects "universal plans" that have no plan_accounts rows)
- TargetAccounts field on PlanRequest
- rollbackPlan closure: deletes the partial purchase_plans row if
  validatePlanAccountProviders or SetPlanAccounts fail
- Three regression tests: RejectsEmptyTargetAccounts, UnknownAccountReturns404,
  DBErrorOnCreateReturns500WithLog
@cristim
cristim force-pushed the fix/create-plan-500-observability branch from 0fce515 to 94f8805 Compare June 4, 2026 13:47
@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 merged commit 11981c9 into feat/multicloud-web-frontend Jun 5, 2026
4 checks passed
@cristim
cristim deleted the fix/create-plan-500-observability branch June 5, 2026 08:22
cristim added a commit that referenced this pull request Jun 5, 2026
…unt validation log

The GetCloudAccount DB-error path inside validatePlanAccountProviders
returned fmt.Errorf("accounts: failed to get account %s: %w", aid, getErr),
a non-ClientError that propagated to the router's
logging.Errorf("API error: %v"), leaking the raw account UUID and the
raw DB error string into logs (gap left by #946 which closed #944).

Wrap the error with the existing mapCreatePlanStorageError helper so
that: (a) a DB error emits a structured logging.Errorf carrying only
the derived provider list and account count (never the UUID), and
(b) returns a generic 500 ClientError ("failed to validate plan
accounts") with no raw UUID or DB string. The ErrNotFound sentinel
still maps to 404; the explicit (nil, nil) not-found path is unchanged.

Add logging.SetOutput as a test seam on the default logger and two
regression tests:
- TestValidatePlanAccountProviders_GetAccountDBError_NoPIILeak: asserts
  that a DB error yields a 500 ClientError whose message and emitted
  log contain neither the account UUID nor the raw DB error string.
- TestValidatePlanAccountProviders_AccountNotFound_Still404: guards that
  the not-found path still returns a 404 referencing the missing ID.

Also fixes the pre-existing build failure on the base branch
(Session.Role field removed in #940 but the test was not updated) and
gofmt drift in handler_purchases_guards_test.go.

closes #965
cristim added a commit that referenced this pull request Jun 7, 2026
…unt validation log

The GetCloudAccount DB-error path inside validatePlanAccountProviders
returned fmt.Errorf("accounts: failed to get account %s: %w", aid, getErr),
a non-ClientError that propagated to the router's
logging.Errorf("API error: %v"), leaking the raw account UUID and the
raw DB error string into logs (gap left by #946 which closed #944).

Wrap the error with the existing mapCreatePlanStorageError helper so
that: (a) a DB error emits a structured logging.Errorf carrying only
the derived provider list and account count (never the UUID), and
(b) returns a generic 500 ClientError ("failed to validate plan
accounts") with no raw UUID or DB string. The ErrNotFound sentinel
still maps to 404; the explicit (nil, nil) not-found path is unchanged.

Add logging.SetOutput as a test seam on the default logger and two
regression tests:
- TestValidatePlanAccountProviders_GetAccountDBError_NoPIILeak: asserts
  that a DB error yields a 500 ClientError whose message and emitted
  log contain neither the account UUID nor the raw DB error string.
- TestValidatePlanAccountProviders_AccountNotFound_Still404: guards that
  the not-found path still returns a 404 referencing the missing ID.

Also fixes the pre-existing build failure on the base branch
(Session.Role field removed in #940 but the test was not updated) and
gofmt drift in handler_purchases_guards_test.go.

closes #965
cristim added a commit that referenced this pull request Jun 8, 2026
…unt validation log

The GetCloudAccount DB-error path inside validatePlanAccountProviders
returned fmt.Errorf("accounts: failed to get account %s: %w", aid, getErr),
a non-ClientError that propagated to the router's
logging.Errorf("API error: %v"), leaking the raw account UUID and the
raw DB error string into logs (gap left by #946 which closed #944).

Wrap the error with the existing mapCreatePlanStorageError helper so
that: (a) a DB error emits a structured logging.Errorf carrying only
the derived provider list and account count (never the UUID), and
(b) returns a generic 500 ClientError ("failed to validate plan
accounts") with no raw UUID or DB string. The ErrNotFound sentinel
still maps to 404; the explicit (nil, nil) not-found path is unchanged.

Add logging.SetOutput as a test seam on the default logger and two
regression tests:
- TestValidatePlanAccountProviders_GetAccountDBError_NoPIILeak: asserts
  that a DB error yields a 500 ClientError whose message and emitted
  log contain neither the account UUID nor the raw DB error string.
- TestValidatePlanAccountProviders_AccountNotFound_Still404: guards that
  the not-found path still returns a 404 referencing the missing ID.

Also fixes the pre-existing build failure on the base branch
(Session.Role field removed in #940 but the test was not updated) and
gofmt drift in handler_purchases_guards_test.go.

closes #965
cristim added a commit that referenced this pull request Jun 8, 2026
…unt validation log (#969)

* sec(api/plans): stop leaking raw account UUID + DB error in plan-account validation log

The GetCloudAccount DB-error path inside validatePlanAccountProviders
returned fmt.Errorf("accounts: failed to get account %s: %w", aid, getErr),
a non-ClientError that propagated to the router's
logging.Errorf("API error: %v"), leaking the raw account UUID and the
raw DB error string into logs (gap left by #946 which closed #944).

Wrap the error with the existing mapCreatePlanStorageError helper so
that: (a) a DB error emits a structured logging.Errorf carrying only
the derived provider list and account count (never the UUID), and
(b) returns a generic 500 ClientError ("failed to validate plan
accounts") with no raw UUID or DB string. The ErrNotFound sentinel
still maps to 404; the explicit (nil, nil) not-found path is unchanged.

Add logging.SetOutput as a test seam on the default logger and two
regression tests:
- TestValidatePlanAccountProviders_GetAccountDBError_NoPIILeak: asserts
  that a DB error yields a 500 ClientError whose message and emitted
  log contain neither the account UUID nor the raw DB error string.
- TestValidatePlanAccountProviders_AccountNotFound_Still404: guards that
  the not-found path still returns a 404 referencing the missing ID.

Also fixes the pre-existing build failure on the base branch
(Session.Role field removed in #940 but the test was not updated) and
gofmt drift in handler_purchases_guards_test.go.

closes #965

* fix(api/plans): stop leaking raw DB error in plan-fetch validation branch (refs #965)

getPlanForAccountProviderValidation returned fmt.Errorf("accounts: failed to
get plan: %w", err) on a non-ErrNotFound storage error. Because this is not
a ClientError, the router logged it via logging.Errorf("API error: %v"),
emitting the raw DB error string on every POST /plans and PUT /plans/:id/accounts
call that hits a DB failure (same endpoints as the sibling GetCloudAccount leak
closed by PR #969).

Fix: delegate to the existing mapCreatePlanStorageError helper (introduced by
PR #969 for the GetCloudAccount branch) so the response is a generic 500
ClientError and the log line contains only a static format string -- no raw DB
error, no plan UUID.

Adds two regression tests:
- TestGetPlanForAccountProviderValidation_GetPlanDBError_NoPIILeak: asserts
  the handler returns a 500 ClientError and that neither the client response
  nor the captured log buffer contain the raw DB error string; FAILS pre-fix.
- TestGetPlanForAccountProviderValidation_PlanNotFound_Still404: guards that
  ErrNotFound still maps to 404 after the refactor.

* sec(api): fix sibling PII leaks in plan/purchase handler paths (followup #965)

PR #969 closed the raw-UUID + raw-DB-error leak in
validatePlanAccountProviders and getPlanForAccountProviderValidation.
Adversarial review surfaced five sibling leak sites of the exact same
shape -- a non-ClientError wrapped with a raw account/plan UUID and the
underlying DB error -- that all propagate through the router's
`logging.Errorf("API error: %v")` path and leak both the UUID and the
raw DB error string.

Fixed sites (all use `mapCreatePlanStorageError` so a DB error becomes a
generic 500 ClientError, and a structured log line is emitted without
PII):

- handler_plans.go:209 updatePlan -- existingPlan fetch
- handler_plans.go:333 getPlanForPurchaseCreation -- plan fetch for
  createPlannedPurchases
- handler_plans.go:447 patchPlan -- plan fetch for PATCH
- handler_purchases.go:323/328 disablePlan -- GetPurchasePlan and
  UpdatePurchasePlan failure paths
- handler_purchases.go:2006 lookupContactEmail -- the Warnf used to log
  the raw account UUID and the raw err; now logs only the failure point
- handler_purchases.go:1955 gatherAccountContactEmails -- the wrap site
  used to re-leak the same account UUID through `%s: %w`; now returns a
  generic 500 ClientError (lookupContactEmail has already logged without
  PII)

Test changes:

- New: TestHandler_patchPlan_GetPlanDBError_NoPIILeak,
  TestHandler_updatePlan_GetPlanDBError_NoPIILeak,
  TestDisablePlan_GetPlanDBError_NoPIILeak,
  TestDisablePlan_UpdatePlanDBError_NoPIILeak,
  TestLookupContactEmail_GetAccountDBError_NoPIILeak,
  TestGatherAccountContactEmails_DBError_NoPIILeak -- each asserts the
  ClientError is 500 (server-side fault), the returned message contains
  neither the raw UUID nor the raw DB error string, and (where the path
  logs via the default logger) the captured log buffer contains neither
  either. Pattern mirrors the existing
  TestValidatePlanAccountProviders_GetAccountDBError_NoPIILeak.

- Updated: TestHandler_patchPlan_NotFound,
  TestHandler_createPlannedPurchases_PlanNotFound -- assertions moved
  off the legacy "failed to get plan" wrapper string to the new
  contract: 500 ClientError without the raw plan UUID. Both tests
  return a plain `errors.New("not found")` from the mock (not
  config.ErrNotFound) so they exercise the 500 branch, not the 404
  branch.

- Updated: TestHandler_resolveApprovalRecipients_LookupErrorPropagates
  -- the test's stated intent (no silent globalNotify fallback) is
  retained via the require.Error guard; the ErrorIs(transient) assertion
  was guarding the implementation detail that broke the no-PII-leak
  contract. Replaced with 500-ClientError + no-UUID + no-raw-DB-error
  assertions consistent with the rest of the issue #965 family.

Full internal/api suite: 1474 passed; full short suite: only two
pre-existing internal/auth failures unrelated to this PR.

refs #965

* sec(api/purchases): keep lookupContactEmail failure at error level

The GetCloudAccount failure in lookupContactEmail was logged at Warnf,
but its caller gatherAccountContactEmails maps the error into a generic
500 ClientError. The router returns ClientErrors via the branch that
skips logging.Errorf("API error: %v"), so this log line is the only
error-level breadcrumb for the resulting user-visible 500. Downgrading
it to Warnf weakened error-level alerting for exactly the outage this
change set is preserving visibility for.

Restore Errorf while keeping the message generic (no raw account UUID,
no raw DB error string) so the PII-leak fix is unaffected. Extend the
regression test to assert the [ERROR] severity and the generic message;
it fails pre-fix (emits [WARN]) and passes after.

Addresses CodeRabbit review on PR #969.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/all-users Affects every user priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant