feat(deps): p-retry around Documenso fetch + p-queue installed
p-retry wraps every Documenso API call with 3 attempts (1 + 2 retries), exponential backoff (1s → 4s with jitter). AbortError short-circuits on: - 401/403 — auth failures won't fix themselves on retry - 4xx other than 429 — Documenso rejected the payload; retrying hurts more than it helps 5xx + 429 (rate-limit) go through the retry path with backoff so we politely re-attempt after delay. Recovers the single-connection-blip scenario the audit's services pass flagged. p-queue installed too (audit §36.A.1 companion to p-limit). No concrete land site today — we don't bulk-fan-out to Documenso, and existing pLimit covers our internal mass-op fan-outs. Available for future rate-per-second scenarios. Verified: tsc clean, vitest 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -91,6 +91,8 @@
|
|||||||
"nodemailer": "^8.0.7",
|
"nodemailer": "^8.0.7",
|
||||||
"openai": "^6.37.0",
|
"openai": "^6.37.0",
|
||||||
"p-limit": "^7.3.0",
|
"p-limit": "^7.3.0",
|
||||||
|
"p-queue": "^9.2.0",
|
||||||
|
"p-retry": "^8.0.0",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^5.7.284",
|
"pdfjs-dist": "^5.7.284",
|
||||||
|
|||||||
35
pnpm-lock.yaml
generated
35
pnpm-lock.yaml
generated
@@ -199,6 +199,12 @@ importers:
|
|||||||
p-limit:
|
p-limit:
|
||||||
specifier: ^7.3.0
|
specifier: ^7.3.0
|
||||||
version: 7.3.0
|
version: 7.3.0
|
||||||
|
p-queue:
|
||||||
|
specifier: ^9.2.0
|
||||||
|
version: 9.2.0
|
||||||
|
p-retry:
|
||||||
|
specifier: ^8.0.0
|
||||||
|
version: 8.0.0
|
||||||
papaparse:
|
papaparse:
|
||||||
specifier: ^5.5.3
|
specifier: ^5.5.3
|
||||||
version: 5.5.3
|
version: 5.5.3
|
||||||
@@ -5163,6 +5169,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
|
resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
is-network-error@1.3.2:
|
||||||
|
resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
is-number-object@1.1.1:
|
is-number-object@1.1.1:
|
||||||
resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
|
resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -5940,10 +5950,22 @@ packages:
|
|||||||
resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==}
|
resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
p-queue@9.2.0:
|
||||||
|
resolution: {integrity: sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
p-retry@8.0.0:
|
||||||
|
resolution: {integrity: sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A==}
|
||||||
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
p-timeout@3.2.0:
|
p-timeout@3.2.0:
|
||||||
resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
|
resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
p-timeout@7.0.1:
|
||||||
|
resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
package-json-from-dist@1.0.1:
|
package-json-from-dist@1.0.1:
|
||||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||||
|
|
||||||
@@ -12098,6 +12120,8 @@ snapshots:
|
|||||||
|
|
||||||
is-negative-zero@2.0.3: {}
|
is-negative-zero@2.0.3: {}
|
||||||
|
|
||||||
|
is-network-error@1.3.2: {}
|
||||||
|
|
||||||
is-number-object@1.1.1:
|
is-number-object@1.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
@@ -12838,10 +12862,21 @@ snapshots:
|
|||||||
eventemitter3: 4.0.7
|
eventemitter3: 4.0.7
|
||||||
p-timeout: 3.2.0
|
p-timeout: 3.2.0
|
||||||
|
|
||||||
|
p-queue@9.2.0:
|
||||||
|
dependencies:
|
||||||
|
eventemitter3: 5.0.4
|
||||||
|
p-timeout: 7.0.1
|
||||||
|
|
||||||
|
p-retry@8.0.0:
|
||||||
|
dependencies:
|
||||||
|
is-network-error: 1.3.2
|
||||||
|
|
||||||
p-timeout@3.2.0:
|
p-timeout@3.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-finally: 1.0.0
|
p-finally: 1.0.0
|
||||||
|
|
||||||
|
p-timeout@7.0.1: {}
|
||||||
|
|
||||||
package-json-from-dist@1.0.1: {}
|
package-json-from-dist@1.0.1: {}
|
||||||
|
|
||||||
package-manager-detector@1.6.0: {}
|
package-manager-detector@1.6.0: {}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import pRetry, { AbortError } from 'p-retry';
|
||||||
|
|
||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
import { CodedError } from '@/lib/errors';
|
import { CodedError } from '@/lib/errors';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
@@ -22,10 +24,10 @@ async function resolveCreds(portId?: string): Promise<DocumensoCreds> {
|
|||||||
return { baseUrl: cfg.apiUrl, apiKey: cfg.apiKey, apiVersion: cfg.apiVersion };
|
return { baseUrl: cfg.apiUrl, apiKey: cfg.apiKey, apiVersion: cfg.apiVersion };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function documensoFetch(
|
async function documensoFetchOnce(
|
||||||
path: string,
|
path: string,
|
||||||
options?: RequestInit,
|
options: RequestInit | undefined,
|
||||||
portId?: string,
|
portId: string | undefined,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const { baseUrl, apiKey } = await resolveCreds(portId);
|
const { baseUrl, apiKey } = await resolveCreds(portId);
|
||||||
let res: Response;
|
let res: Response;
|
||||||
@@ -40,6 +42,7 @@ async function documensoFetch(
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof FetchTimeoutError) {
|
if (err instanceof FetchTimeoutError) {
|
||||||
|
// Retry timeouts — transient network issue.
|
||||||
throw new CodedError('DOCUMENSO_TIMEOUT', {
|
throw new CodedError('DOCUMENSO_TIMEOUT', {
|
||||||
internalMessage: `${path} timed out after ${err.timeoutMs}ms`,
|
internalMessage: `${path} timed out after ${err.timeoutMs}ms`,
|
||||||
});
|
});
|
||||||
@@ -51,10 +54,24 @@ async function documensoFetch(
|
|||||||
const err = await res.text();
|
const err = await res.text();
|
||||||
logger.error({ path, status: res.status, err, portId }, 'Documenso API error');
|
logger.error({ path, status: res.status, err, portId }, 'Documenso API error');
|
||||||
if (res.status === 401 || res.status === 403) {
|
if (res.status === 401 || res.status === 403) {
|
||||||
throw new CodedError('DOCUMENSO_AUTH_FAILURE', {
|
// Auth failures are not retryable — wrong key won't fix itself.
|
||||||
internalMessage: `${path} → ${res.status}`,
|
throw new AbortError(
|
||||||
});
|
new CodedError('DOCUMENSO_AUTH_FAILURE', {
|
||||||
|
internalMessage: `${path} → ${res.status}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
|
||||||
|
// 4xx (other than 429) means we sent something Documenso rejected —
|
||||||
|
// retrying won't help. 429 (rate-limit) goes through the retry path
|
||||||
|
// with backoff so we politely re-attempt after delay.
|
||||||
|
throw new AbortError(
|
||||||
|
new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
||||||
|
internalMessage: `${path} → ${res.status}: ${err}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 5xx + 429 → transient, retry.
|
||||||
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
||||||
internalMessage: `${path} → ${res.status}: ${err}`,
|
internalMessage: `${path} → ${res.status}: ${err}`,
|
||||||
});
|
});
|
||||||
@@ -63,6 +80,39 @@ async function documensoFetch(
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps every Documenso call in p-retry: 3 attempts total (1 + 2 retries)
|
||||||
|
* with exponential backoff (1s, 4s) + jitter. AbortError short-circuits
|
||||||
|
* for auth failures and 4xx-not-429 — those will never succeed on retry.
|
||||||
|
*
|
||||||
|
* This recovers the "single connection blip drops the whole signing flow"
|
||||||
|
* scenario the audit's services pass flagged.
|
||||||
|
*/
|
||||||
|
async function documensoFetch(
|
||||||
|
path: string,
|
||||||
|
options?: RequestInit,
|
||||||
|
portId?: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return pRetry(() => documensoFetchOnce(path, options, portId), {
|
||||||
|
retries: 2,
|
||||||
|
factor: 2,
|
||||||
|
minTimeout: 1000,
|
||||||
|
randomize: true,
|
||||||
|
onFailedAttempt: (ctx) => {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
path,
|
||||||
|
portId,
|
||||||
|
attempt: ctx.attemptNumber,
|
||||||
|
retriesLeft: ctx.retriesLeft,
|
||||||
|
err: ctx.error.message,
|
||||||
|
},
|
||||||
|
'Documenso fetch retry',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Documenso 2.x renamed top-level `id` → `documentId` and recipient `id` →
|
// Documenso 2.x renamed top-level `id` → `documentId` and recipient `id` →
|
||||||
// `recipientId`; v1.13 still uses `id`. Normalize both shapes to the legacy
|
// `recipientId`; v1.13 still uses `id`. Normalize both shapes to the legacy
|
||||||
// `id` form that this codebase consumes everywhere downstream.
|
// `id` form that this codebase consumes everywhere downstream.
|
||||||
|
|||||||
Reference in New Issue
Block a user