{"record":{"id":"dac4e5f369a564b4","repo":"actualbudget/actual","slug":"cannot-end-a-batch-process-no-batch-started","errorCode":null,"errorMessage":"Cannot end a batch process: no batch started","messagePattern":"Cannot end a batch process: no batch started","errorType":"exception","errorClass":"APIError","httpStatus":null,"severity":"error","filePath":"packages/loot-core/src/server/api.ts","lineNumber":156,"sourceCode":"  // syncing layer in that case.\n  if (IMPORT_MODE) {\n    void db.asyncTransaction(() => {\n      return new Promise((resolve, reject) => {\n        batchPromise = { resolve, reject };\n      });\n    });\n  } else {\n    void batchMessages(() => {\n      return new Promise((resolve, reject) => {\n        batchPromise = { resolve, reject };\n      });\n    });\n  }\n};\n\nhandlers['api/batch-budget-end'] = async function () {\n  if (!batchPromise) {\n    throw APIError('Cannot end a batch process: no batch started');\n  }\n\n  batchPromise.resolve();\n  batchPromise = null;\n};\n\nhandlers['api/load-budget'] = async function ({ id }) {\n  const { id: currentId } = prefs.getPrefs() || {};\n\n  if (currentId !== id) {\n    connection.send('start-load');\n    const { error } = await handlers['load-budget']({ id });\n\n    if (!error) {\n      connection.send('finish-load');\n    } else {\n      connection.send('show-budgets');\n","sourceCodeStart":138,"sourceCodeEnd":174,"githubUrl":"https://github.com/actualbudget/actual/blob/d4334cb6e6123f4d3bcea1ad6166608884c7e658/packages/loot-core/src/server/api.ts#L138-L174","documentation":"api.batchBudgetEnd() resolves the promise created by batchBudgetStart() and clears the module-level batchPromise. If no batch was started, there is nothing to resolve, so the handler throws this APIError.","triggerScenarios":"Calling api.batchBudgetEnd() without a preceding api.batchBudgetStart(), calling it twice, or calling it after a failed start (which never set batchPromise).","commonSituations":"Cleanup code in a finally block that runs even when batchBudgetStart() threw; duplicated end calls across retries; copy-pasted code paths sharing one end call.","solutions":["Only call batchBudgetEnd() after batchBudgetStart() succeeded — move the end call inside try/finally that starts after the try begins","Guard with a flag: only end when a start was recorded","Check for double-invocation (e.g. both a success path and a finally path calling end)"],"exampleFix":"// before\ntry {\n  await api.batchBudgetStart();\n  applyUpdates(items);\n} finally {\n  await api.batchBudgetEnd(); // throws if start threw\n}\n// after\nawait api.batchBudgetStart();\ntry {\n  applyUpdates(items);\n} finally {\n  await api.batchBudgetEnd();\n}","handlingStrategy":"validation","validationCode":"if (!batchPromise) return;","typeGuard":null,"tryCatchPattern":"if (!batchPromise) {\n  logger.warn('batch end with no batch; ignoring');\n  return;\n}\nbatchPromise.resolve();\nbatchPromise = null;","preventionTips":["Make end idempotent or no-op when no batch is active","Track batch lifecycle state explicitly","Log end calls to catch double-invocations"],"tags":["api","batching","state"],"backgroundTag":"operation-not-started","analyzedSha":"d4334cb6e6123f4d3bcea1ad6166608884c7e658","analyzedAt":"2026-08-29T01:02:11.213Z","schemaVersion":2},"datasetVersion":"2026-08-29T02:17:18.158Z"}