@larc-iu/plaid-client 0.2.0-alpha.3 → 0.2.0-alpha.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larc-iu/plaid-client",
3
- "version": "0.2.0-alpha.3",
3
+ "version": "0.2.0-alpha.5",
4
4
  "description": "JavaScript client for the Plaid annotation API",
5
5
  "type": "module",
6
6
  "repository": {
package/src/http.js CHANGED
@@ -5,6 +5,12 @@ import { transformRequest, transformResponse } from './transforms.js';
5
5
  // also bounds media up/downloads — bump it (or disable) for very large files.
6
6
  export const DEFAULT_TIMEOUT_MS = 30000;
7
7
 
8
+ // Batch submissions get their own, longer budget. A batch runs as ONE server
9
+ // transaction holding the single SQLite write lock for its whole duration, and
10
+ // aborting the HTTP request does not cancel it — so a short timeout buys
11
+ // nothing and costs a retry stacked on a write that is still running.
12
+ export const DEFAULT_BATCH_TIMEOUT_MS = 180000;
13
+
8
14
  /**
9
15
  * Extract and update document versions from response headers and body.
10
16
  */
@@ -71,6 +77,41 @@ export function makeNetworkError(originalError, url, method) {
71
77
  return error;
72
78
  }
73
79
 
80
+ // Retry budget for 503 "Database busy" responses. Plaid serializes writers on
81
+ // a single SQLite write lock; a writer that can't get it within the server's
82
+ // busy_timeout is refused with 503. That refusal is definitive — the
83
+ // transaction never opened, or was rolled back whole — so repeating the
84
+ // request is safe, including for a batch (which is all-or-nothing by
85
+ // construction). Retrying here is what lets a long import ride out someone
86
+ // else's slow write instead of dying partway through.
87
+ export const BUSY_RETRIES = 4;
88
+ export const BUSY_BACKOFF_MS = 250;
89
+
90
+ /**
91
+ * Run `attempt`, retrying while it rejects with a 503. Exponential backoff
92
+ * with full jitter, so two clients that collide don't march in lockstep and
93
+ * collide again on every retry.
94
+ *
95
+ * `attempt` must perform the whole request, not just await a prepared one:
96
+ * a fetch body and an AbortSignal.timeout are both single-use.
97
+ */
98
+ export async function retryWhileBusy(attempt, {
99
+ retries = BUSY_RETRIES,
100
+ baseDelayMs = BUSY_BACKOFF_MS,
101
+ onRetry,
102
+ } = {}) {
103
+ for (let i = 0; ; i += 1) {
104
+ try {
105
+ return await attempt();
106
+ } catch (error) {
107
+ if (error?.status !== 503 || i >= retries) throw error;
108
+ const delay = Math.round(baseDelayMs * 2 ** i * (0.5 + Math.random()));
109
+ onRetry?.({ attempt: i + 1, retries, delay, error });
110
+ await new Promise((resolve) => setTimeout(resolve, delay));
111
+ }
112
+ }
113
+ }
114
+
74
115
  /**
75
116
  * Build a fetch AbortSignal that fires after `timeout` ms, or undefined when
76
117
  * timeouts are disabled / unsupported.
@@ -91,6 +132,13 @@ export function timeoutSignal(timeout) {
91
132
  * formData - If true, body is FormData; skip Content-Type header
92
133
  * queryParams - Object of query param key/values to append
93
134
  * noBatch - If true, throw when in batch mode
135
+ * bypassBatch - If true, go over the wire even while a batch is open.
136
+ * For READS only: a batch is a write transaction, its
137
+ * ops return no value to their caller until submit, and
138
+ * the sub-request runs against the tx Connection rather
139
+ * than the pool. A read swallowed by an ambient batch is
140
+ * therefore useless to the caller AND can fail the whole
141
+ * batch (see `query`).
94
142
  * skipResponseTransform - Return raw parsed JSON (no transformResponse)
95
143
  * noAuth - Skip Authorization header
96
144
  * binaryResponse - Return arrayBuffer instead of JSON/text
@@ -105,6 +153,7 @@ export async function makeRequest(client, method, path, options = {}) {
105
153
  formData,
106
154
  queryParams,
107
155
  noBatch,
156
+ bypassBatch,
108
157
  skipResponseTransform,
109
158
  noAuth,
110
159
  binaryResponse,
@@ -178,8 +227,11 @@ export async function makeRequest(client, method, path, options = {}) {
178
227
  group.written = true;
179
228
  }
180
229
 
181
- // Batch mode
182
- if (client.isBatching) {
230
+ // Batch mode. A `bypassBatch` read is deliberately NOT queued: it belongs to
231
+ // whoever called it, not to whatever batch happens to be open on this shared
232
+ // client (a document reconcile, an import), and it reads the pre-batch state
233
+ // exactly as it would have if the batch were not running.
234
+ if (client.isBatching && !bypassBatch) {
183
235
  if (noBatch) {
184
236
  throw new Error(`This endpoint cannot be used in batch mode: ${path}`);
185
237
  }
@@ -207,11 +259,24 @@ export async function makeRequest(client, method, path, options = {}) {
207
259
  if (requestBody !== undefined) {
208
260
  fetchOptions.body = formData ? requestBody : JSON.stringify(requestBody);
209
261
  }
210
- const signal = timeoutSignal(timeout !== undefined ? timeout : client.timeout);
211
- if (signal) fetchOptions.signal = signal;
262
+
263
+ // A fresh AbortSignal.timeout per attempt: the signal fires once and stays
264
+ // aborted, so reusing it would make every retry fail instantly.
265
+ const attemptOptions = () => {
266
+ const signal = timeoutSignal(timeout !== undefined ? timeout : client.timeout);
267
+ return signal ? { ...fetchOptions, signal } : fetchOptions;
268
+ };
212
269
 
213
270
  try {
214
- const response = await fetch(url, fetchOptions);
271
+ const response = await retryWhileBusy(async () => {
272
+ const res = await fetch(url, attemptOptions());
273
+ // Surface a 503 as a throw so retryWhileBusy can see it; other failures
274
+ // are re-thrown from here and handled by the caller below.
275
+ if (res.status === 503) {
276
+ throw makeHttpError(res, await parseErrorBody(res), url, method);
277
+ }
278
+ return res;
279
+ });
215
280
 
216
281
  if (!response.ok) {
217
282
  const error = makeHttpError(response, await parseErrorBody(response), url, method);