fix(api): map run-now failures by cause and stamp its audit fields - #495
Merged
Merged
Conversation
Every RunPlannedPurchaseNow error used to come back as a 409 "cannot be started", including a provider failure or a partial completion where money had already moved. The handler now maps a 4-eyes refusal to 403, a lost claim or missing row to 409, a store error before the claim to 500, an unsaved final status to 500, and an execution failure to 502 carrying the row's finalized status (failed / partially_completed). To tell "never started" from "ran and failed", RunPlannedPurchaseNow now returns the claimed row (nil when the CAS was never won), and every 4-eyes refusal wraps the new purchase.ErrFourEyesDenied sentinel. The success response reports that row's status instead of a hardcoded "completed", and the executed email uses the post-run row instead of the pre-run snapshot. Run-now also stamps executed_at, executed_by_user_id and pre_approval_skip_reason = "run-now" like direct-execute does, but after the CAS is won and in the same save as approved_by: a pre-claim save of the stale row could revert a concurrent scheduler claim. Refs #386
Contributor
|
Warning Review limit reached
This review includes 12 billable files and costs up to $3.00.
Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing. Or wait 5 minutes for your next included review. View limit detailsLimit details: You’ve used all 2 included reviews currently available. Your 58 included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour. Review configuration: ⚙️ Run configuration
📒 Files selected for processing (12)
Comment |
runNowError checked ErrNotFound / ErrExecutionNotInExpectedStatus before final == nil, so a plan deleted mid-run (or an audit-loss wrapping ErrNotFound) after the claim was reported as "cannot be started" (409) although the row was already failed or approved. Apply the 409 only when no claim was won; otherwise fall through to 502/500 with the real status. Also reword the executed_by_user_id / pre_approval_skip_reason docs: the four-eyes check runs on run-now rows and ApprovedBy is stamped. Refs #386
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Item 3 of #386 (from the #370 review):
runPlannedPurchase("Run now") ininternal/api/handler_purchases.go.RunPlannedPurchaseNowerror came back as409 "cannot be started", including a provider failure or a partial completion where money had already moved. Now:ErrExecutionNotInExpectedStatus) or missing row (ErrNotFound): 409ErrAuditLoss): 500statusandexecution_idin the JSON body"completed". The executed email now uses the post-run row, not the pre-run snapshot.executed_at,executed_by_user_id(the session user's UUID) andpre_approval_skip_reason = "run-now", the same columns direct-execute fills.How
purchase.Manager.RunPlannedPurchaseNowreturns(*config.PurchaseExecution, revocationToken, error). The row is non-nil once the CAS is won, including when execution fails, and nil when the run never started. The handler uses that to tell "never started" from "ran and failed".ApproveAndExecute's signature does not change.checkDifferentApprovernow wraps a newpurchase.ErrFourEyesDeniedsentinel ("approval declined"), so most messages read the same as before. A failure to evaluate the policy (config or store error) is not wrapped and still maps to 500.transitionApproveAndExecuteafter the CAS is won, in the same save asapproved_by. It is deliberately not a pre-claim save likedirectExecutePurchasedoes: a run-now row lives long and the scheduler can claim it concurrently, so saving the stale pre-claim snapshot could set arunningrow back topendingand re-arm it.Verification
New Postgres testcontainers integration tests (
internal/api/handler_purchases_run_now_integration_test.go) use a real store, the real manager and a stub provider, and call the handler.Against
origin/main(ed44785), with the test file copied in:With the fix: all 4 pass. As a mutation check, I dropped
updated.ExecutedAt = &now: the stamp and partial-failure tests failed again, and passed once I put the line back.Also run with
GOTOOLCHAIN=go1.26.6 GOWORK=off:go build ./...go vet ./...andgo vet -tags integration ./...internal/{api,purchase,server,testutil,config}: 3995 passed-tags integrationforinternal/apiandinternal/purchase: all passgolangci-lint run ./internal/...: no issuesThere is also a new table test,
TestRunNowError, for the mapping branches. All of this is fixture-based; no real purchase was made.Deliberately left
directExecutePurchaseandapprovePurchaseViaSession. They callApproveAndExecute, which does not return the claimed row, so they cannot yet tell "never started" from "ran and failed". Fixing them means wideningApproveAndExecutethe same way. That is better done as a follow-up than squeezed into this PR.directExecutePurchasesaves its audit stamp before the claim, so a 4-eyes-denied direct execute keepsexecuted_aton a row that never ran. That path is not changed here.Refs #386