{"record":{"id":"e0b85d7b0941657c","repo":"medusajs/medusa","slug":"cannot-revert-a-transaction-that-is-already-compen","errorCode":null,"errorMessage":"Cannot revert a transaction that is already compensating.","messagePattern":"Cannot revert a transaction that is already compensating\\.","errorType":"exception","errorClass":"MedusaError","httpStatus":null,"severity":"error","filePath":"packages/core/orchestration/src/transaction/transaction-orchestrator.ts","lineNumber":1483,"sourceCode":"      throw new MedusaError(\n        MedusaError.Types.NOT_ALLOWED,\n        `TransactionModel \"${transaction.modelId}\" cannot be orchestrated by \"${this.id}\" model.`\n      )\n    }\n\n    const flow = transaction.getFlow()\n    if (flow.state === TransactionState.FAILED) {\n      throw new MedusaError(\n        MedusaError.Types.NOT_ALLOWED,\n        `Cannot revert a permanent failed transaction.`\n      )\n    }\n\n    if (\n      flow.state === TransactionState.COMPENSATING ||\n      flow.state === TransactionState.WAITING_TO_COMPENSATE\n    ) {\n      throw new MedusaError(\n        MedusaError.Types.NOT_ALLOWED,\n        `Cannot revert a transaction that is already compensating.`\n      )\n    }\n\n    flow.state = TransactionState.WAITING_TO_COMPENSATE\n    flow.cancelledAt = Date.now()\n\n    await transaction.saveCheckpoint()\n\n    if (options?.preventExecuteNext) {\n      return\n    }\n\n    await this.executeNext(transaction)\n  }\n\n  private parseFlowOptions() {","sourceCodeStart":1465,"sourceCodeEnd":1501,"githubUrl":"https://github.com/medusajs/medusa/blob/5e06e544a296b9033f20f71f11c559f81a0e5739/packages/core/orchestration/src/transaction/transaction-orchestrator.ts#L1465-L1501","documentation":"A transaction whose flow is already in COMPENSATING or WAITING_TO_COMPENSATE state cannot be asked to revert again — compensation is already underway. The orchestrator throws NOT_ALLOWED to prevent double-triggering rollback.","triggerScenarios":"Calling cancelTransaction/revert twice (e.g. double-click, duplicated webhook), or calling it after an earlier call already moved the flow to WAITING_TO_COMPENSATE but before compensation finished.","commonSituations":"Non-idempotent cancel endpoints; concurrent cancel requests racing each other; monitoring tooling that automatically retries cancellation on timeout.","solutions":["Make cancel idempotent: check flow.state before calling and treat compensating as 'already in progress'","Guard the endpoint with a lock or dedupe on transactionId","Return 200/accepted when already compensating instead of erroring"],"exampleFix":"// before\napp.post('/cancel/:id', async () => {\n  await orchestrator.cancelTransaction(tx)\n})\n\n// after\nconst state = tx.getFlow().state\nif (state === 'compensating' || state === 'waiting_to_compensate') {\n  return { status: 'already-compensating' }\n}\nawait orchestrator.cancelTransaction(tx)","handlingStrategy":"validation","validationCode":"const state = tx.getFlow().state\nif (state !== 'compensating' && state !== 'waiting_to_compensate') {\n  await orchestrator.cancelTransaction(tx)\n}","typeGuard":"const isCompensating = (tx) =>\n  ['compensating', 'waiting_to_compensate'].includes(tx.getFlow().state)","tryCatchPattern":"try { await orchestrator.cancelTransaction(tx) } catch (e) { if (e.type === 'not_allowed' && /already compensating/.test(e.message)) return { status: 'already-compensating' } throw e }","preventionTips":["Make cancel endpoints idempotent and dedupe concurrent cancel requests by transaction id"],"tags":["orchestration","workflow","compensation","idempotency"],"backgroundTag":"invalid-workflow-state-transition","analyzedSha":"5e06e544a296b9033f20f71f11c559f81a0e5739","analyzedAt":"2026-08-27T07:24:39.599Z","schemaVersion":2},"datasetVersion":"2026-08-27T08:17:20.692Z"}