actual-mcp-server 0.6.27 → 0.6.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/package.json +2 -2
- package/dist/src/lib/actual-adapter.js +28 -32
- package/dist/src/lib/retry.js +40 -0
- package/package.json +2 -2
package/README.md
CHANGED
package/dist/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actual-mcp-server",
|
|
3
3
|
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.6.
|
|
4
|
+
"version": "0.6.28",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"verify-tools": "npm run build && node scripts/verify-tools.js",
|
|
31
31
|
"check:coverage": "node scripts/list-actual-api-methods.mjs",
|
|
32
32
|
"direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
|
|
33
|
-
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js",
|
|
34
34
|
"test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
|
|
35
35
|
"test:e2e": "npx playwright test",
|
|
36
36
|
"test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
|
|
@@ -11,7 +11,7 @@ const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, import
|
|
|
11
11
|
} = api;
|
|
12
12
|
import { EventEmitter } from 'events';
|
|
13
13
|
import observability from '../observability.js';
|
|
14
|
-
import retry from './retry.js';
|
|
14
|
+
import retry, { isRetryableError } from './retry.js';
|
|
15
15
|
import logger from '../logger.js';
|
|
16
16
|
import config from '../config.js';
|
|
17
17
|
import { parseBudgetRegistry } from './budget-registry.js';
|
|
@@ -130,17 +130,13 @@ function _hasPooledConnection(sessionId) {
|
|
|
130
130
|
* actually corrupted but the error pattern doesn't match, the next call's op
|
|
131
131
|
* will surface the same root cause and we'll catch it then.
|
|
132
132
|
*/
|
|
133
|
+
// Whether an error is infrastructure-level (drop the pooled connection so the
|
|
134
|
+
// next call re-inits cleanly). This is the SAME class as "retryable", so it
|
|
135
|
+
// delegates to isRetryableError (#177): the pool-drop decision and the retry
|
|
136
|
+
// decision share one pattern list and cannot drift. A consistency test pins
|
|
137
|
+
// this equivalence.
|
|
133
138
|
function _shouldDropPoolOnError(err) {
|
|
134
|
-
|
|
135
|
-
return false;
|
|
136
|
-
const msg = err.message || '';
|
|
137
|
-
return (msg.includes('Authentication failed') ||
|
|
138
|
-
msg.includes('ECONNRESET') ||
|
|
139
|
-
msg.includes('ECONNREFUSED') ||
|
|
140
|
-
msg.includes('socket hang up') ||
|
|
141
|
-
msg.includes('ETIMEDOUT') ||
|
|
142
|
-
msg.includes('out of memory') ||
|
|
143
|
-
msg.includes('ENOMEM'));
|
|
139
|
+
return isRetryableError(err);
|
|
144
140
|
}
|
|
145
141
|
/**
|
|
146
142
|
* Enforce per-request budget ACL before any pool branching or lock acquisition.
|
|
@@ -897,7 +893,7 @@ export async function addTransactions(txs, options = {}) {
|
|
|
897
893
|
return rest;
|
|
898
894
|
});
|
|
899
895
|
// API docs say it returns id[], but reality is it can return "ok", array of IDs, or Transaction objects
|
|
900
|
-
const result = await withConcurrency(() => retry(() => rawAddTransactions(accountId, cleanedTxs, options), { retries: 2, backoffMs: 200 }));
|
|
896
|
+
const result = await withConcurrency(() => retry(() => rawAddTransactions(accountId, cleanedTxs, options), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
901
897
|
// Handle various return formats
|
|
902
898
|
if (result === 'ok') {
|
|
903
899
|
// Transaction created successfully but no IDs returned
|
|
@@ -924,7 +920,7 @@ export async function addTransactions(txs, options = {}) {
|
|
|
924
920
|
export async function importTransactions(accountId, txs) {
|
|
925
921
|
observability.incrementToolCall('actual.transactions.import').catch(() => { });
|
|
926
922
|
return queueWriteOperation(async () => {
|
|
927
|
-
const raw = await withConcurrency(() => retry(() => rawImportTransactions(accountId, txs), { retries: 2, backoffMs: 200 }));
|
|
923
|
+
const raw = await withConcurrency(() => retry(() => rawImportTransactions(accountId, txs), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
928
924
|
return raw || { added: [], updated: [], errors: [] };
|
|
929
925
|
});
|
|
930
926
|
}
|
|
@@ -957,7 +953,7 @@ export async function createTransfer(params) {
|
|
|
957
953
|
payee: transferPayee.id,
|
|
958
954
|
...(params.notes !== undefined && { notes: params.notes }),
|
|
959
955
|
};
|
|
960
|
-
await withConcurrency(() => retry(() => rawAddTransactions(params.from_account, [sourceTx], { runTransfers: true }), { retries: 2, backoffMs: 200 }));
|
|
956
|
+
await withConcurrency(() => retry(() => rawAddTransactions(params.from_account, [sourceTx], { runTransfers: true }), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
961
957
|
return { success: true };
|
|
962
958
|
});
|
|
963
959
|
if (!writeResult.success)
|
|
@@ -998,7 +994,7 @@ export async function createCategory(category) {
|
|
|
998
994
|
observability.incrementToolCall('actual.categories.create').catch(() => { });
|
|
999
995
|
return queueWriteOperation(async () => {
|
|
1000
996
|
try {
|
|
1001
|
-
const raw = await withConcurrency(() => retry(() => rawCreateCategory(category), { retries: 0, backoffMs: 200 }));
|
|
997
|
+
const raw = await withConcurrency(() => retry(() => rawCreateCategory(category), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1002
998
|
return normalizeToId(raw);
|
|
1003
999
|
}
|
|
1004
1000
|
catch (error) {
|
|
@@ -1022,7 +1018,7 @@ export async function getPayees() {
|
|
|
1022
1018
|
export async function createPayee(payee) {
|
|
1023
1019
|
observability.incrementToolCall('actual.payees.create').catch(() => { });
|
|
1024
1020
|
return queueWriteOperation(async () => {
|
|
1025
|
-
const raw = await withConcurrency(() => retry(() => rawCreatePayee(payee), { retries: 2, backoffMs: 200 }));
|
|
1021
|
+
const raw = await withConcurrency(() => retry(() => rawCreatePayee(payee), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1026
1022
|
return normalizeToId(raw);
|
|
1027
1023
|
});
|
|
1028
1024
|
}
|
|
@@ -1047,7 +1043,7 @@ export async function setBudgetAmount(month, categoryId, amount) {
|
|
|
1047
1043
|
if (!exists) {
|
|
1048
1044
|
throw new Error(`Category "${categoryId}" not found. Use actual_categories_get to list available categories.`);
|
|
1049
1045
|
}
|
|
1050
|
-
const result = await withConcurrency(() => retry(() => rawSetBudgetAmount(month, categoryId, amount), { retries: 2, backoffMs: 200 }));
|
|
1046
|
+
const result = await withConcurrency(() => retry(() => rawSetBudgetAmount(month, categoryId, amount), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1051
1047
|
return result;
|
|
1052
1048
|
});
|
|
1053
1049
|
}
|
|
@@ -1091,7 +1087,7 @@ export async function transferBudgetAmount(month, fromCategoryId, toCategoryId,
|
|
|
1091
1087
|
export async function createAccount(account, initialBalance) {
|
|
1092
1088
|
observability.incrementToolCall('actual.accounts.create').catch(() => { });
|
|
1093
1089
|
return queueWriteOperation(async () => {
|
|
1094
|
-
const raw = await withConcurrency(() => retry(() => rawCreateAccount(account, initialBalance), { retries: 2, backoffMs: 200 }));
|
|
1090
|
+
const raw = await withConcurrency(() => retry(() => rawCreateAccount(account, initialBalance), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1095
1091
|
const id = normalizeToId(raw);
|
|
1096
1092
|
// NO NEED for syncToServer() - shutdown() will handle persistence
|
|
1097
1093
|
return id;
|
|
@@ -1100,7 +1096,7 @@ export async function createAccount(account, initialBalance) {
|
|
|
1100
1096
|
export async function updateAccount(id, fields) {
|
|
1101
1097
|
observability.incrementToolCall('actual.accounts.update').catch(() => { });
|
|
1102
1098
|
return queueWriteOperation(async () => {
|
|
1103
|
-
await withConcurrency(() => retry(() => rawUpdateAccount(id, fields), { retries: 2, backoffMs: 200 }));
|
|
1099
|
+
await withConcurrency(() => retry(() => rawUpdateAccount(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1104
1100
|
return null;
|
|
1105
1101
|
});
|
|
1106
1102
|
}
|
|
@@ -1143,7 +1139,7 @@ export async function updateTransaction(id, fields) {
|
|
|
1143
1139
|
observability.incrementToolCall('actual.transactions.update').catch(() => { });
|
|
1144
1140
|
// Use write queue to batch concurrent updates in a single budget session
|
|
1145
1141
|
return queueWriteOperation(async () => {
|
|
1146
|
-
await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200 }));
|
|
1142
|
+
await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1147
1143
|
});
|
|
1148
1144
|
}
|
|
1149
1145
|
export async function updateTransactionBatch(updates) {
|
|
@@ -1156,7 +1152,7 @@ export async function updateTransactionBatch(updates) {
|
|
|
1156
1152
|
const failed = [];
|
|
1157
1153
|
for (const { id, fields } of updates) {
|
|
1158
1154
|
try {
|
|
1159
|
-
await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200 }));
|
|
1155
|
+
await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1160
1156
|
succeeded.push({ id });
|
|
1161
1157
|
}
|
|
1162
1158
|
catch (err) {
|
|
@@ -1179,7 +1175,7 @@ export async function deleteTransaction(id) {
|
|
|
1179
1175
|
export async function updateCategory(id, fields) {
|
|
1180
1176
|
observability.incrementToolCall('actual.categories.update').catch(() => { });
|
|
1181
1177
|
return queueWriteOperation(async () => {
|
|
1182
|
-
await withConcurrency(() => retry(() => rawUpdateCategory(id, fields), { retries: 2, backoffMs: 200 }));
|
|
1178
|
+
await withConcurrency(() => retry(() => rawUpdateCategory(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1183
1179
|
});
|
|
1184
1180
|
}
|
|
1185
1181
|
export async function deleteCategory(id) {
|
|
@@ -1210,7 +1206,7 @@ export async function updatePayee(id, fields) {
|
|
|
1210
1206
|
}
|
|
1211
1207
|
// Update the payee's direct fields (name, transfer_acct, etc.)
|
|
1212
1208
|
if (Object.keys(directFields).length > 0) {
|
|
1213
|
-
await withConcurrency(() => retry(() => rawUpdatePayee(id, directFields), { retries: 2, backoffMs: 200 }));
|
|
1209
|
+
await withConcurrency(() => retry(() => rawUpdatePayee(id, directFields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1214
1210
|
}
|
|
1215
1211
|
// Handle category via the rules mechanism (same approach Actual Budget uses internally)
|
|
1216
1212
|
if (categoryValue !== undefined) {
|
|
@@ -1230,7 +1226,7 @@ export async function updatePayee(id, fields) {
|
|
|
1230
1226
|
...setCategoryRule,
|
|
1231
1227
|
actions: setCategoryRule.actions.map((a) => a.op === 'set' && a.field === 'category' ? { ...a, value: categoryValue } : a),
|
|
1232
1228
|
};
|
|
1233
|
-
await withConcurrency(() => retry(() => rawUpdateRule(updatedRule), { retries: 0, backoffMs: 200 }));
|
|
1229
|
+
await withConcurrency(() => retry(() => rawUpdateRule(updatedRule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1234
1230
|
logger.debug(`[UPDATE PAYEE] Updated default category rule for payee ${id} to category ${categoryValue}`);
|
|
1235
1231
|
}
|
|
1236
1232
|
}
|
|
@@ -1242,7 +1238,7 @@ export async function updatePayee(id, fields) {
|
|
|
1242
1238
|
conditions: [{ op: 'is', field: 'payee', value: id }],
|
|
1243
1239
|
actions: [{ op: 'set', field: 'category', value: categoryValue }],
|
|
1244
1240
|
};
|
|
1245
|
-
await withConcurrency(() => retry(() => rawCreateRule(newRule), { retries: 0, backoffMs: 200 }));
|
|
1241
|
+
await withConcurrency(() => retry(() => rawCreateRule(newRule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1246
1242
|
logger.debug(`[UPDATE PAYEE] Created default category rule for payee ${id} with category ${categoryValue}`);
|
|
1247
1243
|
}
|
|
1248
1244
|
// category=null + no existing rule = no-op (already clear)
|
|
@@ -1271,7 +1267,7 @@ export async function getRules() {
|
|
|
1271
1267
|
export async function createRule(rule) {
|
|
1272
1268
|
observability.incrementToolCall('actual.rules.create').catch(() => { });
|
|
1273
1269
|
return queueWriteOperation(async () => {
|
|
1274
|
-
const raw = await withConcurrency(() => retry(() => rawCreateRule(rule), { retries: 2, backoffMs: 200 }));
|
|
1270
|
+
const raw = await withConcurrency(() => retry(() => rawCreateRule(rule), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1275
1271
|
const id = normalizeToId(raw);
|
|
1276
1272
|
return id;
|
|
1277
1273
|
});
|
|
@@ -1295,7 +1291,7 @@ export async function updateRule(id, fields) {
|
|
|
1295
1291
|
actions: fieldsObj.actions ?? existingRule.actions ?? [],
|
|
1296
1292
|
};
|
|
1297
1293
|
logger.debug(`[UPDATE RULE] Updating rule ${id} with merged fields: ${JSON.stringify(rule)}`);
|
|
1298
|
-
await withConcurrency(() => retry(() => rawUpdateRule(rule), { retries: 0, backoffMs: 200 }));
|
|
1294
|
+
await withConcurrency(() => retry(() => rawUpdateRule(rule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1299
1295
|
logger.debug(`[UPDATE RULE] Update completed for rule ${id}`);
|
|
1300
1296
|
});
|
|
1301
1297
|
}
|
|
@@ -1317,7 +1313,7 @@ export async function createSchedule(schedule) {
|
|
|
1317
1313
|
return queueWriteOperation(async () => {
|
|
1318
1314
|
// Note: rawCreateSchedule(schedule) passes the external schedule object directly.
|
|
1319
1315
|
// Do NOT wrap in { schedule: ... } — that would double-nest and break date parsing.
|
|
1320
|
-
const raw = await withConcurrency(() => retry(() => rawCreateSchedule(schedule), { retries: 0, backoffMs: 200 }));
|
|
1316
|
+
const raw = await withConcurrency(() => retry(() => rawCreateSchedule(schedule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1321
1317
|
const id = normalizeToId(raw);
|
|
1322
1318
|
return id;
|
|
1323
1319
|
});
|
|
@@ -1325,7 +1321,7 @@ export async function createSchedule(schedule) {
|
|
|
1325
1321
|
export async function updateSchedule(id, fields, resetNextDate) {
|
|
1326
1322
|
observability.incrementToolCall('actual.schedules.update').catch(() => { });
|
|
1327
1323
|
return queueWriteOperation(async () => {
|
|
1328
|
-
await withConcurrency(() => retry(() => rawUpdateSchedule(id, fields, resetNextDate), { retries: 0, backoffMs: 200 }));
|
|
1324
|
+
await withConcurrency(() => retry(() => rawUpdateSchedule(id, fields, resetNextDate), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1329
1325
|
});
|
|
1330
1326
|
}
|
|
1331
1327
|
export async function deleteSchedule(id) {
|
|
@@ -1337,7 +1333,7 @@ export async function deleteSchedule(id) {
|
|
|
1337
1333
|
export async function setBudgetCarryover(month, categoryId, flag) {
|
|
1338
1334
|
observability.incrementToolCall('actual.budgets.setCarryover').catch(() => { });
|
|
1339
1335
|
return queueWriteOperation(async () => {
|
|
1340
|
-
await withConcurrency(() => retry(() => rawSetBudgetCarryover(month, categoryId, flag), { retries: 2, backoffMs: 200 }));
|
|
1336
|
+
await withConcurrency(() => retry(() => rawSetBudgetCarryover(month, categoryId, flag), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1341
1337
|
});
|
|
1342
1338
|
}
|
|
1343
1339
|
export async function closeAccount(id) {
|
|
@@ -1363,7 +1359,7 @@ export async function getCategoryGroups() {
|
|
|
1363
1359
|
export async function createCategoryGroup(group) {
|
|
1364
1360
|
observability.incrementToolCall('actual.category_groups.create').catch(() => { });
|
|
1365
1361
|
return queueWriteOperation(async () => {
|
|
1366
|
-
const raw = await withConcurrency(() => retry(() => rawCreateCategoryGroup(group), { retries: 2, backoffMs: 200 }));
|
|
1362
|
+
const raw = await withConcurrency(() => retry(() => rawCreateCategoryGroup(group), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1367
1363
|
const id = normalizeToId(raw);
|
|
1368
1364
|
return id;
|
|
1369
1365
|
});
|
|
@@ -1371,7 +1367,7 @@ export async function createCategoryGroup(group) {
|
|
|
1371
1367
|
export async function updateCategoryGroup(id, fields) {
|
|
1372
1368
|
observability.incrementToolCall('actual.category_groups.update').catch(() => { });
|
|
1373
1369
|
return queueWriteOperation(async () => {
|
|
1374
|
-
await withConcurrency(() => retry(() => rawUpdateCategoryGroup(id, fields), { retries: 2, backoffMs: 200 }));
|
|
1370
|
+
await withConcurrency(() => retry(() => rawUpdateCategoryGroup(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1375
1371
|
});
|
|
1376
1372
|
}
|
|
1377
1373
|
export async function deleteCategoryGroup(id) {
|
package/dist/src/lib/retry.js
CHANGED
|
@@ -1,9 +1,41 @@
|
|
|
1
1
|
import { DEFAULT_RETRY_ATTEMPTS, DEFAULT_RETRY_BACKOFF_MS, MAX_RETRY_DELAY_MS } from './constants.js';
|
|
2
2
|
import { ModuleLoggers } from './loggerFactory.js';
|
|
3
3
|
const log = ModuleLoggers.RETRY;
|
|
4
|
+
/**
|
|
5
|
+
* Error-message fragments that mark a TRANSIENT / infrastructure-level failure:
|
|
6
|
+
* the kind a retry can actually recover from, and the kind worth dropping a
|
|
7
|
+
* pooled connection over. Single source of truth for #177: the adapter's
|
|
8
|
+
* `_shouldDropPoolOnError` delegates to `isRetryableError`, so the retry
|
|
9
|
+
* decision and the pool-drop decision cannot drift apart.
|
|
10
|
+
*
|
|
11
|
+
* Anything NOT matching here (domain/validation errors such as "is required",
|
|
12
|
+
* "not found", "does not exist", Zod failures, and any unknown error) is
|
|
13
|
+
* terminal: it fails the same way on every attempt, so it must NOT be retried.
|
|
14
|
+
*/
|
|
15
|
+
export const TRANSIENT_ERROR_PATTERNS = [
|
|
16
|
+
'Authentication failed',
|
|
17
|
+
'ECONNRESET',
|
|
18
|
+
'ECONNREFUSED',
|
|
19
|
+
'socket hang up',
|
|
20
|
+
'ETIMEDOUT',
|
|
21
|
+
'out of memory',
|
|
22
|
+
'ENOMEM',
|
|
23
|
+
];
|
|
24
|
+
/**
|
|
25
|
+
* True only for known transient/infrastructure errors (#177). Non-Error and
|
|
26
|
+
* unknown rejections return false (fail fast), so a deterministic domain error
|
|
27
|
+
* is never retried.
|
|
28
|
+
*/
|
|
29
|
+
export function isRetryableError(err) {
|
|
30
|
+
if (!(err instanceof Error))
|
|
31
|
+
return false;
|
|
32
|
+
const msg = err.message || '';
|
|
33
|
+
return TRANSIENT_ERROR_PATTERNS.some(p => msg.includes(p));
|
|
34
|
+
}
|
|
4
35
|
export async function retry(fn, opts) {
|
|
5
36
|
const retries = opts?.retries ?? DEFAULT_RETRY_ATTEMPTS;
|
|
6
37
|
const backoffMs = opts?.backoffMs ?? DEFAULT_RETRY_BACKOFF_MS;
|
|
38
|
+
const isRetryable = opts?.isRetryable;
|
|
7
39
|
let attempt = 0;
|
|
8
40
|
while (true) {
|
|
9
41
|
try {
|
|
@@ -12,6 +44,14 @@ export async function retry(fn, opts) {
|
|
|
12
44
|
return result;
|
|
13
45
|
}
|
|
14
46
|
catch (err) {
|
|
47
|
+
// Fail fast on a non-retryable (domain/validation) error when a classifier
|
|
48
|
+
// is supplied: retrying cannot help and only wastes work plus log noise
|
|
49
|
+
// (#177). With no classifier, behaviour is unchanged (retry until the
|
|
50
|
+
// attempt budget is exhausted), preserving every existing call site.
|
|
51
|
+
if (isRetryable && !isRetryable(err)) {
|
|
52
|
+
log.debug('Not retrying non-transient error', { error: err?.message });
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
15
55
|
attempt++;
|
|
16
56
|
if (attempt > retries) {
|
|
17
57
|
log.error(`All retry attempts exhausted after ${retries} tries`, err);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actual-mcp-server",
|
|
3
3
|
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.6.
|
|
4
|
+
"version": "0.6.28",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"verify-tools": "npm run build && node scripts/verify-tools.js",
|
|
31
31
|
"check:coverage": "node scripts/list-actual-api-methods.mjs",
|
|
32
32
|
"direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
|
|
33
|
-
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js",
|
|
34
34
|
"test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
|
|
35
35
|
"test:e2e": "npx playwright test",
|
|
36
36
|
"test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
|