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

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.4",
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.
@@ -207,11 +248,24 @@ export async function makeRequest(client, method, path, options = {}) {
207
248
  if (requestBody !== undefined) {
208
249
  fetchOptions.body = formData ? requestBody : JSON.stringify(requestBody);
209
250
  }
210
- const signal = timeoutSignal(timeout !== undefined ? timeout : client.timeout);
211
- if (signal) fetchOptions.signal = signal;
251
+
252
+ // A fresh AbortSignal.timeout per attempt: the signal fires once and stays
253
+ // aborted, so reusing it would make every retry fail instantly.
254
+ const attemptOptions = () => {
255
+ const signal = timeoutSignal(timeout !== undefined ? timeout : client.timeout);
256
+ return signal ? { ...fetchOptions, signal } : fetchOptions;
257
+ };
212
258
 
213
259
  try {
214
- const response = await fetch(url, fetchOptions);
260
+ const response = await retryWhileBusy(async () => {
261
+ const res = await fetch(url, attemptOptions());
262
+ // Surface a 503 as a throw so retryWhileBusy can see it; other failures
263
+ // are re-thrown from here and handled by the caller below.
264
+ if (res.status === 503) {
265
+ throw makeHttpError(res, await parseErrorBody(res), url, method);
266
+ }
267
+ return res;
268
+ });
215
269
 
216
270
  if (!response.ok) {
217
271
  const error = makeHttpError(response, await parseErrorBody(response), url, method);