From 28c788ff41b038fcf5ba2f0dbb38f81b6239007e Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 12 May 2026 23:50:29 +0200 Subject: [PATCH] feat(deps): p-retry around Documenso fetch + p-queue installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- package.json | 2 + pnpm-lock.yaml | 35 ++++++++++++++++ src/lib/services/documenso-client.ts | 62 +++++++++++++++++++++++++--- 3 files changed, 93 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index cbeacecd..6b996036 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,8 @@ "nodemailer": "^8.0.7", "openai": "^6.37.0", "p-limit": "^7.3.0", + "p-queue": "^9.2.0", + "p-retry": "^8.0.0", "papaparse": "^5.5.3", "pdf-lib": "^1.17.1", "pdfjs-dist": "^5.7.284", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6d3df11..7183ccb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,6 +199,12 @@ importers: p-limit: specifier: ^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: specifier: ^5.5.3 version: 5.5.3 @@ -5163,6 +5169,10 @@ packages: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} 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: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} @@ -5940,10 +5950,22 @@ packages: resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} 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: resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} 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: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -12098,6 +12120,8 @@ snapshots: is-negative-zero@2.0.3: {} + is-network-error@1.3.2: {} + is-number-object@1.1.1: dependencies: call-bound: 1.0.4 @@ -12838,10 +12862,21 @@ snapshots: eventemitter3: 4.0.7 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: dependencies: p-finally: 1.0.0 + p-timeout@7.0.1: {} + package-json-from-dist@1.0.1: {} package-manager-detector@1.6.0: {} diff --git a/src/lib/services/documenso-client.ts b/src/lib/services/documenso-client.ts index 56efcb70..faf3a9c3 100644 --- a/src/lib/services/documenso-client.ts +++ b/src/lib/services/documenso-client.ts @@ -1,3 +1,5 @@ +import pRetry, { AbortError } from 'p-retry'; + import { env } from '@/lib/env'; import { CodedError } from '@/lib/errors'; import { logger } from '@/lib/logger'; @@ -22,10 +24,10 @@ async function resolveCreds(portId?: string): Promise { return { baseUrl: cfg.apiUrl, apiKey: cfg.apiKey, apiVersion: cfg.apiVersion }; } -async function documensoFetch( +async function documensoFetchOnce( path: string, - options?: RequestInit, - portId?: string, + options: RequestInit | undefined, + portId: string | undefined, ): Promise { const { baseUrl, apiKey } = await resolveCreds(portId); let res: Response; @@ -40,6 +42,7 @@ async function documensoFetch( }); } catch (err) { if (err instanceof FetchTimeoutError) { + // Retry timeouts — transient network issue. throw new CodedError('DOCUMENSO_TIMEOUT', { internalMessage: `${path} timed out after ${err.timeoutMs}ms`, }); @@ -51,10 +54,24 @@ async function documensoFetch( const err = await res.text(); logger.error({ path, status: res.status, err, portId }, 'Documenso API error'); if (res.status === 401 || res.status === 403) { - throw new CodedError('DOCUMENSO_AUTH_FAILURE', { - internalMessage: `${path} → ${res.status}`, - }); + // Auth failures are not retryable — wrong key won't fix itself. + 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', { internalMessage: `${path} → ${res.status}: ${err}`, }); @@ -63,6 +80,39 @@ async function documensoFetch( 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 { + 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` → // `recipientId`; v1.13 still uses `id`. Normalize both shapes to the legacy // `id` form that this codebase consumes everywhere downstream.