actual-mcp-server 0.16.6 → 0.16.7
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 +2 -1
- package/dist/src/config.js +28 -0
- package/dist/src/lib/actual-adapter.js +53 -5
- package/dist/src/lib/apiState.js +14 -0
- package/dist/src/lib/budgetLoader.js +51 -22
- package/dist/src/lib/opTimeout.js +13 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -476,6 +476,7 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
|
|
|
476
476
|
| `ACTUAL_BUDGET_PASSWORD` | _(none)_ | No | Optional encryption password for encrypted budgets |
|
|
477
477
|
| `ALLOW_INSECURE_UPSTREAM` | `false` | No | Allow an `http://` upstream even when `ACTUAL_BUDGET_PASSWORD` is set (#161). Off by default so a plaintext upstream plus an encryption password is refused |
|
|
478
478
|
| `ACTUAL_OP_TIMEOUT_MS` | `30000` | No | Per-operation timeout (ms) for every upstream Actual API call (init, budget download, sync, and each tool operation). A stalled call rejects after this bound so it cannot hold the global API mutex forever and hang subsequent tool calls (#270). Set to `0` to disable |
|
|
479
|
+
| `ACTUAL_IMPORT_TIMEOUT_MS` | `600000` | No | Separate, larger timeout (ms) for a budget import, which is legitimately long rather than stalled. An import is a tracked load, so every other session waits on it; bounding it at the general operation timeout turned one tenant's large import into a process-wide stall (#407). Set to `0` to disable |
|
|
479
480
|
| **MCP Server Settings** ||||
|
|
480
481
|
| `MCP_BRIDGE_PORT` | `3600` | No | Port for MCP server to listen on |
|
|
481
482
|
| `MCP_BRIDGE_BIND_HOST` | `0.0.0.0` | No | Host address to bind server to (`0.0.0.0` = all interfaces) |
|
|
@@ -774,7 +775,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
774
775
|
|
|
775
776
|
---
|
|
776
777
|
|
|
777
|
-
**Version:** 0.16.
|
|
778
|
+
**Version:** 0.16.7 | **Tool Count:** 74 (verified LibreChat-compatible)
|
|
778
779
|
|
|
779
780
|
## Sponsor
|
|
780
781
|
|
package/dist/src/config.js
CHANGED
|
@@ -30,6 +30,34 @@ export const configSchema = z.object({
|
|
|
30
30
|
return 0;
|
|
31
31
|
return Math.min(Math.max(n, 250), 2147483647);
|
|
32
32
|
}),
|
|
33
|
+
/**
|
|
34
|
+
* #407: a separate, larger bound for a budget IMPORT.
|
|
35
|
+
*
|
|
36
|
+
* An import is not a stalled operation, it is a legitimately long one: a large YNAB or zip
|
|
37
|
+
* import runs for minutes. #394 made it a TRACKED load, so while it is in flight every other
|
|
38
|
+
* session's lock acquisition waits for it and then fails at ACTUAL_OP_TIMEOUT_MS. Bounding a
|
|
39
|
+
* five-minute import at the 30 second operation default therefore turned one tenant's import
|
|
40
|
+
* into a process-wide stall, with every other session failing repeatedly for the duration.
|
|
41
|
+
*
|
|
42
|
+
* Same parse-and-clamp shape as ACTUAL_OP_TIMEOUT_MS, and the same escape hatch: 0 disables the
|
|
43
|
+
* bound, which for an import means it can hold the process-global api mutex indefinitely (the
|
|
44
|
+
* #393 P0 shape), so it is for diagnosis rather than production.
|
|
45
|
+
*
|
|
46
|
+
* The clamp floor is 250ms, the same as the operation timeout. It is deliberately NOT raised to
|
|
47
|
+
* the operation timeout's configured VALUE: an earlier comment here claimed an import bound below
|
|
48
|
+
* the general one was prevented, and it was not, so the claim is removed rather than left to
|
|
49
|
+
* mislead. A smaller import bound is unusual but coherent (abandon imports sooner than ordinary
|
|
50
|
+
* reads), and enforcing an ordering between two independently configured knobs at parse time
|
|
51
|
+
* would need one to be parsed before the other.
|
|
52
|
+
*/
|
|
53
|
+
ACTUAL_IMPORT_TIMEOUT_MS: z.string().default('600000').transform(val => {
|
|
54
|
+
const n = parseInt(val, 10);
|
|
55
|
+
if (!Number.isFinite(n) || n < 0)
|
|
56
|
+
return 600000;
|
|
57
|
+
if (n === 0)
|
|
58
|
+
return 0;
|
|
59
|
+
return Math.min(Math.max(n, 250), 2147483647);
|
|
60
|
+
}),
|
|
33
61
|
// Escape hatch for #161: allow an http:// upstream even when an E2E encryption
|
|
34
62
|
// password is set (e.g. an isolated Docker network where the hop is trusted).
|
|
35
63
|
// Off by default so a plaintext upstream + encryption password is refused.
|
|
@@ -21,7 +21,7 @@ import { parseBudgetRegistry } from './budget-registry.js';
|
|
|
21
21
|
import { getPreferredBudgetSyncId, setPreferredBudgetSyncId, pickAllowedPreferredBudget } from './budget-preference-store.js';
|
|
22
22
|
import { requestContext } from './requestContext.js';
|
|
23
23
|
import { connectionPool } from './ActualConnectionPool.js';
|
|
24
|
-
import { isApiInitialized, setApiInitialized, getLoadedBudgetSyncId,
|
|
24
|
+
import { isApiInitialized, setApiInitialized, getLoadedBudgetSyncId, awaitAbandonedBudgetLoad } from './apiState.js';
|
|
25
25
|
import { withApiLock } from './apiLock.js';
|
|
26
26
|
import { loadBudgetTracked, importBudgetTracked } from './budgetLoader.js';
|
|
27
27
|
/**
|
|
@@ -830,7 +830,7 @@ async function processWriteQueue() {
|
|
|
830
830
|
//
|
|
831
831
|
// Each op keeps its own withOpTimeout (#270) and still settles individually, so one
|
|
832
832
|
// stalled or rejected operation cannot hold the api mutex or fail its siblings.
|
|
833
|
-
for (const { operation, resolve, reject, preservesListings, requestStore } of batch) {
|
|
833
|
+
for (const { operation, resolve, reject, preservesListings, requestStore, timeoutMs } of batch) {
|
|
834
834
|
try {
|
|
835
835
|
// #390 round 2: run each op in the context it was ENQUEUED in.
|
|
836
836
|
//
|
|
@@ -847,8 +847,40 @@ async function processWriteQueue() {
|
|
|
847
847
|
// been correct wrong. Re-entering the captured store makes each op resolve its own
|
|
848
848
|
// budget, which is what #158 captured a per-entry sessionId for in the first place.
|
|
849
849
|
const result = await requestContext.run(requestStore ?? {}, async () => {
|
|
850
|
+
// #406: settle any abandoned budget load PER OPERATION, not per lock acquisition.
|
|
851
|
+
//
|
|
852
|
+
// #393 made the wait part of acquiring the api lock, which makes the set of call
|
|
853
|
+
// sites stop mattering: nothing reaches the api without the lock, so nothing reaches
|
|
854
|
+
// it without the wait. That holds for every path where one acquisition serves one
|
|
855
|
+
// operation. It does NOT hold here. This drain acquires the lock ONCE and then runs
|
|
856
|
+
// N operations inside it, so once any op abandons a load, every LATER op in the same
|
|
857
|
+
// batch runs against a singleton that abandoned load will re-point, having never
|
|
858
|
+
// waited for it. Reproduced with a slow import and a following write in one debounce
|
|
859
|
+
// window; it is the #390 class with a new author.
|
|
860
|
+
//
|
|
861
|
+
// Deliberately NOT inside ensureLoadedBudgetMatchesSession: that returns early when
|
|
862
|
+
// the loaded budget already matches, and an op whose budget happens to match still
|
|
863
|
+
// needs the wait, because the abandoned load is about to move the singleton AWAY
|
|
864
|
+
// from it. The wait has to be unconditional, so it sits before the precondition.
|
|
865
|
+
//
|
|
866
|
+
// A rejection here is caught by this loop's own per-op catch, so a stuck load fails
|
|
867
|
+
// ONE operation rather than the whole batch, which is the behaviour #393 round 4
|
|
868
|
+
// established for the drain.
|
|
869
|
+
await awaitAbandonedBudgetLoad(withOpTimeout);
|
|
850
870
|
await ensureLoadedBudgetMatchesSession();
|
|
851
|
-
|
|
871
|
+
// #407: an operation may carry its OWN bound. Only a budget import does today, and
|
|
872
|
+
// it needs one: the import is legitimately long, and bounding it at the general
|
|
873
|
+
// operation timeout abandons it while every other session is already waiting on it
|
|
874
|
+
// as a tracked load. Without this the raised bound inside the loader is dead letter,
|
|
875
|
+
// because THIS wrapper would abandon the op first.
|
|
876
|
+
//
|
|
877
|
+
// A long op does NOT need a keep-alive heartbeat, and one was written and removed in
|
|
878
|
+
// review. The session cannot be swept out from under it: `cleanupIdleConnections`
|
|
879
|
+
// passes `onlyIfExpired`, so `shutdownConnectionLocked` re-checks expiry AFTER
|
|
880
|
+
// acquiring the mutex (#392), and the `finally` below touches every session in the
|
|
881
|
+
// batch before the lock is released. A sweep that queued behind this drain therefore
|
|
882
|
+
// finds the entry fresh and returns without evicting.
|
|
883
|
+
return await withOpTimeout(operation, 'operation', timeoutMs);
|
|
852
884
|
});
|
|
853
885
|
resolve(result);
|
|
854
886
|
}
|
|
@@ -1021,6 +1053,7 @@ function queueWriteOperation(operation, options) {
|
|
|
1021
1053
|
sessionId,
|
|
1022
1054
|
requestStore: requestContext.getStore(),
|
|
1023
1055
|
preservesListings: options?.preservesListings,
|
|
1056
|
+
timeoutMs: options?.timeoutMs,
|
|
1024
1057
|
};
|
|
1025
1058
|
// #278: bound queue RESIDENCY, not just execution. #270's withOpTimeout bounds an
|
|
1026
1059
|
// operation that is RUNNING; it cannot bound one that never starts. Reuse the same
|
|
@@ -2898,17 +2931,32 @@ export async function importBudget(input, opts = {}) {
|
|
|
2898
2931
|
// #390 still holds at this call site: `importBudgetTracked` records the `imported:` sentinel
|
|
2899
2932
|
// when the promise settles, whichever way, and the clear below states the same fact at the
|
|
2900
2933
|
// call site where the guard in tests/unit/budget_selection_precondition.test.js can see it.
|
|
2934
|
+
// #408: invalidate BEFORE the import starts, not after it resolves.
|
|
2935
|
+
//
|
|
2936
|
+
// The invalidation below used to run only after `queueWriteOperation` resolved, so on the
|
|
2937
|
+
// TIMEOUT path it never ran at all, and a large import exceeding ACTUAL_OP_TIMEOUT_MS is the
|
|
2938
|
+
// expected case rather than a rare one. Every pool entry then kept naming the pre-import
|
|
2939
|
+
// syncId while the singleton moved to the imported file, and switchBudget's #172 fast path
|
|
2940
|
+
// no-opped on a switch back: the reported-success-with-no-effect class #349 exists to prevent.
|
|
2941
|
+
//
|
|
2942
|
+
// Moving it here follows the same clear-before-you-start discipline #394 established for the
|
|
2943
|
+
// record itself: a mutation that may be abandoned must leave state INDETERMINATE, never
|
|
2944
|
+
// confidently wrong. It is idempotent and costs nothing on the success path, where the call
|
|
2945
|
+
// after the write simply re-applies the same sentinel.
|
|
2946
|
+
const preInvalidated = connectionPool.invalidateAllLoadedSyncIds('imported:pending');
|
|
2947
|
+
if (preInvalidated > 0) {
|
|
2948
|
+
logger.debug(`[ADAPTER] importBudget: invalidated the pooled syncId on ${preInvalidated} session(s) before starting`);
|
|
2949
|
+
}
|
|
2901
2950
|
const imported = await importBudgetTracked(() => {
|
|
2902
2951
|
const started = withConcurrency(() => retry(() => rawImportBudget(input, opts), {
|
|
2903
2952
|
retries: 0,
|
|
2904
2953
|
backoffMs: 200,
|
|
2905
2954
|
isRetryable: isRetryableError,
|
|
2906
2955
|
}));
|
|
2907
|
-
setLoadedBudgetSyncId(null);
|
|
2908
2956
|
return started;
|
|
2909
2957
|
});
|
|
2910
2958
|
return imported;
|
|
2911
|
-
});
|
|
2959
|
+
}, { timeoutMs: config.ACTUAL_IMPORT_TIMEOUT_MS });
|
|
2912
2960
|
// #349: an import CHANGES WHICH BUDGET IS LOADED, so the pool's record of it
|
|
2913
2961
|
// must stop naming the old one.
|
|
2914
2962
|
//
|
package/dist/src/lib/apiState.js
CHANGED
|
@@ -131,3 +131,17 @@ export async function awaitAbandonedBudgetLoad(bound) {
|
|
|
131
131
|
export function _hasPendingBudgetLoadForTests() {
|
|
132
132
|
return _pendingBudgetLoads.size > 0;
|
|
133
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Test hook: drop every registration.
|
|
136
|
+
*
|
|
137
|
+
* There is deliberately no production unregister: an entry leaves this set only when its promise
|
|
138
|
+
* settles, which is what makes "is a load outstanding" mean anything (#393). But a test that
|
|
139
|
+
* registers a NEVER-settling load has no way back, and leaving one pending poisons every later
|
|
140
|
+
* lock acquisition in that file, so a later case silently exercises a poisoned lock while claiming
|
|
141
|
+
* to test something else. Both of this repo's abandoned-load test files hit exactly that.
|
|
142
|
+
*
|
|
143
|
+
* Test-only, and not exported through the package surface.
|
|
144
|
+
*/
|
|
145
|
+
export function _clearPendingBudgetLoadsForTests() {
|
|
146
|
+
_pendingBudgetLoads.clear();
|
|
147
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import api from '@actual-app/api';
|
|
2
2
|
import { withOpTimeout } from './opTimeout.js';
|
|
3
|
+
import config from '../config.js';
|
|
3
4
|
import { setApiInitialized, setLoadedBudgetSyncId, registerBudgetLoad } from './apiState.js';
|
|
4
5
|
import { createModuleLogger } from './loggerFactory.js';
|
|
5
6
|
const log = createModuleLogger('BUDGET-LOADER');
|
|
@@ -181,6 +182,9 @@ export function _clearReasonCacheForTests() {
|
|
|
181
182
|
reasonCache.clear();
|
|
182
183
|
}
|
|
183
184
|
async function diagnose(syncId) {
|
|
185
|
+
// #409: what the cache held before a re-diagnosis, so a failed re-diagnosis can fall back to it
|
|
186
|
+
// rather than reporting nothing.
|
|
187
|
+
let staleReason;
|
|
184
188
|
// #396 review: memoise the reason STRING, never the fail-closed decision. Without this, every
|
|
185
189
|
// tool call while the condition persists pays two bounded network calls WHILE HOLDING the
|
|
186
190
|
// process-global api mutex, so a single unloadable budget stalls unrelated sessions for up to
|
|
@@ -199,9 +203,16 @@ async function diagnose(syncId) {
|
|
|
199
203
|
log.debug('reusing the cached load-failure reason', { syncId, uses: cached.uses });
|
|
200
204
|
return cached.reason || undefined;
|
|
201
205
|
}
|
|
202
|
-
// #404: the entry has served its budget of calls
|
|
203
|
-
//
|
|
204
|
-
|
|
206
|
+
// #404: the entry has served its budget of calls, so fall through to a fresh diagnosis and
|
|
207
|
+
// pick up a failure mode that changed without an intervening success.
|
|
208
|
+
//
|
|
209
|
+
// #409: do NOT delete it here. Deleting first meant a re-diagnosis that then failed
|
|
210
|
+
// transiently (a `getBudgets` blip) left the caller with "upstream discarded the reason"
|
|
211
|
+
// despite a perfectly good cached one having existed a moment earlier, and the next call paid
|
|
212
|
+
// two more bounded network calls inside the api mutex to rediscover it. The stale entry is
|
|
213
|
+
// kept as a fallback and is only overwritten when a new answer is actually produced.
|
|
214
|
+
staleReason = cached.reason;
|
|
215
|
+
cached.uses = 0;
|
|
205
216
|
log.debug('re-diagnosing the load failure after the cached reason served its budget', {
|
|
206
217
|
syncId,
|
|
207
218
|
afterUses: REDIAGNOSE_AFTER_USES,
|
|
@@ -235,6 +246,13 @@ async function diagnose(syncId) {
|
|
|
235
246
|
// stay in THIS brace-balanced block: the guard's window ends at the first `}` that takes
|
|
236
247
|
// depth below zero, so a setter in a sibling `catch` or `finally` arm would be outside it.
|
|
237
248
|
setLoadedBudgetSyncId(null);
|
|
249
|
+
// #407 review (I3): DROP any cached reason here. #409 stopped deleting the entry before a
|
|
250
|
+
// re-diagnosis, which is right for the failed-diagnosis path, but on THIS path the old
|
|
251
|
+
// reason has just been proven wrong: the load worked. Leaving it cached with uses reset
|
|
252
|
+
// meant the next 20 calls reported a permanent-sounding migration code for a condition the
|
|
253
|
+
// diagnosis had just shown to be transient, which is exactly the reading the comment below
|
|
254
|
+
// says to avoid.
|
|
255
|
+
reasonCache.delete(syncId);
|
|
238
256
|
// #396 review: the retry SUCCEEDED, so this was not a deterministic unloadable budget. Say
|
|
239
257
|
// so rather than reporting "upstream discarded the reason", which reads as permanent. The
|
|
240
258
|
// operation still fails (the record is indeterminate by design, which forces a re-select),
|
|
@@ -262,6 +280,13 @@ async function diagnose(syncId) {
|
|
|
262
280
|
catch (diagErr) {
|
|
263
281
|
// A failing diagnostic is LOGGED, never substituted for the post-condition error.
|
|
264
282
|
log.warn('could not diagnose why the budget did not load', { syncId, error: messageOf(diagErr) });
|
|
283
|
+
// #409: fall back to the previous answer rather than reporting nothing. It described this same
|
|
284
|
+
// budget's failure moments ago and is far more useful than silence; the use counter was reset,
|
|
285
|
+
// so the next call re-attempts the diagnosis anyway.
|
|
286
|
+
if (staleReason !== undefined) {
|
|
287
|
+
reasonCache.set(syncId, { reason: staleReason, uses: 0 });
|
|
288
|
+
return staleReason || undefined;
|
|
289
|
+
}
|
|
265
290
|
return undefined;
|
|
266
291
|
}
|
|
267
292
|
}
|
|
@@ -294,10 +319,15 @@ function postConditionError(syncId, reason) {
|
|
|
294
319
|
* `verify` is #396's post-condition, and it runs BEFORE the record is written, so the record can
|
|
295
320
|
* only ever describe a mutation that actually took effect.
|
|
296
321
|
*/
|
|
297
|
-
async function trackBudgetMutation(start, describeLoaded, label, verify, onRecorded) {
|
|
298
|
-
|
|
299
|
-
//
|
|
322
|
+
async function trackBudgetMutation(start, describeLoaded, label, verify, onRecorded, timeoutMs) {
|
|
323
|
+
// Indeterminate from here until it settles. Cleared BEFORE the load is even started, so the
|
|
324
|
+
// record can never name the outgoing budget while the singleton is moving away from it: that is
|
|
325
|
+
// the one direction that makes #390's precondition silently pass. It used to be cleared on the
|
|
326
|
+
// statement AFTER `start()`, which was safe in practice (both run in one synchronous turn, so
|
|
327
|
+
// nothing could observe the gap) but made the invariant untestable and the comment's own word
|
|
328
|
+
// "first" untrue.
|
|
300
329
|
setLoadedBudgetSyncId(null);
|
|
330
|
+
const raw = start();
|
|
301
331
|
const tracked = raw
|
|
302
332
|
.then(async (result) => {
|
|
303
333
|
if (verify)
|
|
@@ -329,7 +359,7 @@ async function trackBudgetMutation(start, describeLoaded, label, verify, onRecor
|
|
|
329
359
|
registerBudgetLoad(tracked);
|
|
330
360
|
// On timeout the tracked promise is still running. Leave it REGISTERED: the next operation
|
|
331
361
|
// waits for it inside the lock rather than racing it.
|
|
332
|
-
return await withOpTimeout(() => tracked, label);
|
|
362
|
+
return await withOpTimeout(() => tracked, label, timeoutMs);
|
|
333
363
|
}
|
|
334
364
|
/**
|
|
335
365
|
* #394: the same discipline for an IMPORT.
|
|
@@ -362,24 +392,23 @@ export async function importBudgetTracked(start, label = 'importBudget') {
|
|
|
362
392
|
// outside the ACL, so no session can ever legitimately match it and every session's next
|
|
363
393
|
// operation re-selects its own budget. Configured sync ids are UUIDs, so an `imported:` prefix
|
|
364
394
|
// can never compare equal to one.
|
|
365
|
-
|
|
395
|
+
// #407: the import's OWN bound. See ACTUAL_IMPORT_TIMEOUT_MS in config.ts for why a long
|
|
396
|
+
// import must not be abandoned at the ordinary operation timeout: it is tracked, so every other
|
|
397
|
+
// session waits on it, and abandoning it early turns one tenant's import into a process-wide
|
|
398
|
+
// stall without making the import itself any faster.
|
|
399
|
+
return trackBudgetMutation(start, (result) => `imported:${result.id}`, label, undefined, undefined, config.ACTUAL_IMPORT_TIMEOUT_MS);
|
|
366
400
|
}
|
|
367
401
|
export async function loadBudgetTracked(syncId, encryptionPassword, label = 'downloadBudget') {
|
|
368
402
|
try {
|
|
369
|
-
await trackBudgetMutation(
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
// that structurally, rather than exempting this file from the guard, is what keeps the
|
|
379
|
-
// #390 protection worth having.
|
|
380
|
-
setLoadedBudgetSyncId(null);
|
|
381
|
-
return started;
|
|
382
|
-
}, () => syncId, label,
|
|
403
|
+
await trackBudgetMutation(
|
|
404
|
+
// #410: no redundant clear here any more. `trackBudgetMutation` owns the clear-before, and
|
|
405
|
+
// the guard in tests/unit/budget_selection_precondition.test.js now asserts the invariant
|
|
406
|
+
// that actually holds (a raw load must be inside the tracked helper) rather than looking for
|
|
407
|
+
// a setter in the same block, which had stopped discriminating.
|
|
408
|
+
() => encryptionPassword
|
|
409
|
+
? api
|
|
410
|
+
.downloadBudget(syncId, { password: encryptionPassword })
|
|
411
|
+
: api.downloadBudget(syncId), () => syncId, label,
|
|
383
412
|
// #396: the post-condition runs INSIDE the tracked chain, before the record is written, so
|
|
384
413
|
// an ABANDONED load is verified when it lands rather than recording a success nobody
|
|
385
414
|
// checked. The network enrichment below deliberately does NOT run there.
|
|
@@ -28,15 +28,25 @@ import config from '../config.js';
|
|
|
28
28
|
* before `Promise.race` is built (which would orphan the armed timer and later
|
|
29
29
|
* fire an unhandledRejection).
|
|
30
30
|
*/
|
|
31
|
-
export function withOpTimeout(fn, label = 'operation') {
|
|
32
|
-
|
|
31
|
+
export function withOpTimeout(fn, label = 'operation', overrideMs, overrideKnob = 'ACTUAL_IMPORT_TIMEOUT_MS') {
|
|
32
|
+
// #407: an explicit override, for an operation that is legitimately long rather than stalled.
|
|
33
|
+
// A budget import is the only one today. Everything else keeps the general bound, because the
|
|
34
|
+
// whole point of #270 is that one stalled call must not hold the process-global mutex.
|
|
35
|
+
const timeoutMs = overrideMs === undefined ? config.ACTUAL_OP_TIMEOUT_MS : overrideMs;
|
|
36
|
+
// #407 review (I4): name the knob that ACTUALLY applies. Hardcoding ACTUAL_OP_TIMEOUT_MS sent an
|
|
37
|
+
// operator whose import died at the import bound to raise a variable with no effect on imports.
|
|
38
|
+
//
|
|
39
|
+
// The name is a PARAMETER rather than inferred from "an override exists" (round 2): inferring it
|
|
40
|
+
// is correct only while exactly one call site passes an override, and the second one would
|
|
41
|
+
// silently report the import knob for a different bound, which is the same misdirection.
|
|
42
|
+
const knob = overrideMs === undefined ? 'ACTUAL_OP_TIMEOUT_MS' : overrideKnob;
|
|
33
43
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
34
44
|
return fn();
|
|
35
45
|
}
|
|
36
46
|
let timer;
|
|
37
47
|
const timeout = new Promise((_, reject) => {
|
|
38
48
|
timer = setTimeout(() => {
|
|
39
|
-
reject(new Error(`Actual API ${label} timed out after ${timeoutMs}ms (
|
|
49
|
+
reject(new Error(`Actual API ${label} timed out after ${timeoutMs}ms (${knob}). ` +
|
|
40
50
|
'The upstream Actual server may be unreachable or stalled; the operation was ' +
|
|
41
51
|
'aborted so the session can recover. Please retry the request.'));
|
|
42
52
|
}, timeoutMs);
|