@open-mercato/channel-gmail 0.6.6-develop.5523.1.e223ca1915 → 0.6.6-develop.5531.1.ab1959dfae

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.
@@ -2,6 +2,11 @@ const GMAIL_API_BASE = "https://gmail.googleapis.com/gmail/v1";
2
2
  const GMAIL_MAX_RETRIES = 3;
3
3
  const GMAIL_BACKOFF_BASE_MS = 500;
4
4
  const GMAIL_BACKOFF_CAP_MS = 8e3;
5
+ const GMAIL_DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
6
+ function resolveGmailRequestTimeoutMs() {
7
+ const fromEnv = Number.parseInt(process.env.OM_CHANNEL_GMAIL_REQUEST_TIMEOUT_MS ?? "", 10);
8
+ return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : GMAIL_DEFAULT_REQUEST_TIMEOUT_MS;
9
+ }
5
10
  class FetchGmailApiClient {
6
11
  async listHistory(auth, input) {
7
12
  const url = new URL(`${GMAIL_API_BASE}/users/me/history`);
@@ -63,7 +68,31 @@ class FetchGmailApiClient {
63
68
  let attempt = 0;
64
69
  let lastError = null;
65
70
  while (attempt <= GMAIL_MAX_RETRIES) {
66
- const res = await fetch(url.toString(), { method, headers, body: payload });
71
+ let res;
72
+ try {
73
+ res = await fetch(url.toString(), {
74
+ method,
75
+ headers,
76
+ body: payload,
77
+ // Bound each attempt so a stalled connection fails fast instead of
78
+ // hanging on undici's multi-minute default and pinning the worker slot.
79
+ signal: AbortSignal.timeout(resolveGmailRequestTimeoutMs())
80
+ });
81
+ } catch (err) {
82
+ const errName = err?.name;
83
+ const aborted = errName === "TimeoutError" || errName === "AbortError";
84
+ if (!aborted) throw err;
85
+ const timeoutError = new GmailApiError(
86
+ `Gmail API ${method} ${url.pathname} timed out`,
87
+ 599,
88
+ "request timed out"
89
+ );
90
+ if (attempt === GMAIL_MAX_RETRIES) throw timeoutError;
91
+ lastError = timeoutError;
92
+ await sleep(computeBackoff(attempt));
93
+ attempt += 1;
94
+ continue;
95
+ }
67
96
  const text = await res.text();
68
97
  if (res.ok) {
69
98
  if (!text) return void 0;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/channel_gmail/lib/gmail-client.ts"],
4
- "sourcesContent": ["/**\n * Thin Gmail REST API wrapper. Same trade-off as `oauth.ts`: we use `fetch`\n * directly so the adapter doesn't import the `googleapis` SDK at runtime in\n * environments that don't need it (tests, build-only checks). Production code\n * paths still allow swapping to the SDK via `setGmailApiClient(...)` if a\n * downstream package wants the SDK's extra ergonomics.\n *\n * Only the endpoints the adapter actually calls are exposed:\n * - listHistory \u2192 gmail.users.history.list\n * - listMessages \u2192 gmail.users.messages.list (fallback when historyId expired)\n * - getMessageRaw \u2192 gmail.users.messages.get?format=raw\n * - sendRawMessage \u2192 gmail.users.messages.send\n * - getProfile \u2192 gmail.users.getProfile (health + initial historyId)\n * - deleteMessage \u2192 gmail.users.messages.trash (move to trash; matches `deleteMessage: true` capability)\n */\n\nconst GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1'\n\nexport interface GmailApiAuth {\n accessToken: string\n}\n\nexport interface GmailHistoryListInput {\n startHistoryId: string\n /** Optional page token for paging through history results. */\n pageToken?: string\n /** Optional label filter; defaults to INBOX-only changes. */\n labelId?: string\n}\n\nexport interface GmailHistoryRecord {\n id: string\n messagesAdded?: Array<{ message: { id: string; threadId: string; labelIds?: string[] } }>\n messagesDeleted?: Array<{ message: { id: string; threadId: string } }>\n labelsAdded?: Array<{ message: { id: string; threadId: string }; labelIds: string[] }>\n labelsRemoved?: Array<{ message: { id: string; threadId: string }; labelIds: string[] }>\n}\n\nexport interface GmailHistoryListResponse {\n history?: GmailHistoryRecord[]\n nextPageToken?: string\n historyId: string\n}\n\nexport interface GmailMessagesListInput {\n query?: string\n labelIds?: string[]\n pageToken?: string\n maxResults?: number\n}\n\nexport interface GmailMessagesListResponse {\n messages?: Array<{ id: string; threadId: string }>\n nextPageToken?: string\n resultSizeEstimate?: number\n}\n\nexport interface GmailGetMessageRawResponse {\n id: string\n threadId: string\n labelIds?: string[]\n /** Base64URL-encoded RFC2822 message. */\n raw: string\n internalDate?: string\n sizeEstimate?: number\n}\n\nexport interface GmailSendRawInput {\n /** Base64URL-encoded RFC2822 message body. */\n rawBase64Url: string\n /** Optional thread to attach to. */\n threadId?: string\n}\n\nexport interface GmailSendResponse {\n id: string\n threadId: string\n labelIds?: string[]\n}\n\nexport interface GmailProfileResponse {\n emailAddress: string\n messagesTotal?: number\n threadsTotal?: number\n historyId: string\n}\n\nexport interface GmailWatchInput {\n /** Fully-qualified Pub/Sub topic, e.g. `projects/myproj/topics/gmail-inbound`. */\n topicName: string\n /** Defaults to `['INBOX']` so only inbox changes generate notifications. */\n labelIds?: string[]\n /** `include` (default) or `exclude`. */\n labelFilterAction?: 'include' | 'exclude'\n}\n\nexport interface GmailWatchResponse {\n historyId: string\n /** Watch expiration timestamp, ms since epoch. Gmail caps at ~7 days. */\n expiration: string\n}\n\nexport interface GmailApiClient {\n listHistory(auth: GmailApiAuth, input: GmailHistoryListInput): Promise<GmailHistoryListResponse>\n listMessages(auth: GmailApiAuth, input: GmailMessagesListInput): Promise<GmailMessagesListResponse>\n getMessageRaw(auth: GmailApiAuth, messageId: string): Promise<GmailGetMessageRawResponse>\n sendRawMessage(auth: GmailApiAuth, input: GmailSendRawInput): Promise<GmailSendResponse>\n getProfile(auth: GmailApiAuth): Promise<GmailProfileResponse>\n trashMessage(auth: GmailApiAuth, messageId: string): Promise<void>\n /** Spec C \u2014 `gmail.users.watch` registers a Pub/Sub topic for push delivery. */\n watchInbox(auth: GmailApiAuth, input: GmailWatchInput): Promise<GmailWatchResponse>\n /** Spec C \u2014 `gmail.users.stop` tears down the Pub/Sub registration. */\n stopWatch(auth: GmailApiAuth): Promise<void>\n}\n\nconst GMAIL_MAX_RETRIES = 3\nconst GMAIL_BACKOFF_BASE_MS = 500\nconst GMAIL_BACKOFF_CAP_MS = 8_000\n\nclass FetchGmailApiClient implements GmailApiClient {\n async listHistory(auth: GmailApiAuth, input: GmailHistoryListInput): Promise<GmailHistoryListResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/history`)\n url.searchParams.set('startHistoryId', input.startHistoryId)\n if (input.pageToken) url.searchParams.set('pageToken', input.pageToken)\n url.searchParams.set('labelId', input.labelId ?? 'INBOX')\n url.searchParams.set('historyTypes', 'messageAdded')\n return this.requestJson<GmailHistoryListResponse>(auth, url, 'GET')\n }\n\n async listMessages(auth: GmailApiAuth, input: GmailMessagesListInput): Promise<GmailMessagesListResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/messages`)\n if (input.query) url.searchParams.set('q', input.query)\n for (const label of input.labelIds ?? []) url.searchParams.append('labelIds', label)\n if (input.pageToken) url.searchParams.set('pageToken', input.pageToken)\n if (input.maxResults) url.searchParams.set('maxResults', String(input.maxResults))\n return this.requestJson<GmailMessagesListResponse>(auth, url, 'GET')\n }\n\n async getMessageRaw(auth: GmailApiAuth, messageId: string): Promise<GmailGetMessageRawResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/messages/${encodeURIComponent(messageId)}`)\n url.searchParams.set('format', 'raw')\n return this.requestJson<GmailGetMessageRawResponse>(auth, url, 'GET')\n }\n\n async sendRawMessage(auth: GmailApiAuth, input: GmailSendRawInput): Promise<GmailSendResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/messages/send`)\n return this.requestJson<GmailSendResponse>(auth, url, 'POST', {\n raw: input.rawBase64Url,\n threadId: input.threadId,\n })\n }\n\n async getProfile(auth: GmailApiAuth): Promise<GmailProfileResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/profile`)\n return this.requestJson<GmailProfileResponse>(auth, url, 'GET')\n }\n\n async trashMessage(auth: GmailApiAuth, messageId: string): Promise<void> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/messages/${encodeURIComponent(messageId)}/trash`)\n await this.requestJson(auth, url, 'POST')\n }\n\n async watchInbox(auth: GmailApiAuth, input: GmailWatchInput): Promise<GmailWatchResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/watch`)\n return this.requestJson<GmailWatchResponse>(auth, url, 'POST', {\n topicName: input.topicName,\n labelIds: input.labelIds ?? ['INBOX'],\n labelFilterAction: input.labelFilterAction ?? 'include',\n })\n }\n\n async stopWatch(auth: GmailApiAuth): Promise<void> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/stop`)\n await this.requestJson(auth, url, 'POST')\n }\n\n private async requestJson<T>(auth: GmailApiAuth, url: URL, method: 'GET' | 'POST', body?: unknown): Promise<T> {\n const headers: Record<string, string> = {\n Authorization: `Bearer ${auth.accessToken}`,\n }\n let payload: BodyInit | undefined\n if (body !== undefined) {\n headers['Content-Type'] = 'application/json'\n payload = JSON.stringify(body)\n }\n // Retry transient failures (429, 5xx) with exponential backoff + jitter,\n // honoring `Retry-After` when present. Per Gmail API docs at\n // https://developers.google.com/gmail/api/guides/handle-errors this is the\n // documented mitigation for rate-limit + server-side transient errors.\n let attempt = 0\n let lastError: GmailApiError | null = null\n while (attempt <= GMAIL_MAX_RETRIES) {\n const res = await fetch(url.toString(), { method, headers, body: payload })\n const text = await res.text()\n if (res.ok) {\n if (!text) return undefined as unknown as T\n return JSON.parse(text) as T\n }\n const detail = parseErrorMessage(text) ?? `${res.status} ${res.statusText}`\n const apiError = new GmailApiError(\n `Gmail API ${method} ${url.pathname} failed: ${detail}`,\n res.status,\n detail,\n )\n const transient =\n res.status === 429 ||\n (res.status >= 500 && res.status < 600) ||\n // Gmail signals per-user/project quota exhaustion with HTTP 403 +\n // `rateLimitExceeded`/`userRateLimitExceeded` (not only 429).\n (res.status === 403 && isRateLimit403(text))\n if (!transient || attempt === GMAIL_MAX_RETRIES) {\n throw apiError\n }\n lastError = apiError\n const retryAfterHeader = res.headers.get('retry-after')\n const waitMs =\n parseRetryAfter(retryAfterHeader) ?? computeBackoff(attempt)\n await sleep(waitMs)\n attempt += 1\n }\n throw lastError ?? new GmailApiError(`Gmail API ${method} ${url.pathname} exhausted retries`, 599, 'retries exhausted')\n }\n}\n\n/**\n * Gmail signals quota exhaustion with HTTP 403 + an error reason of\n * `rateLimitExceeded` / `userRateLimitExceeded` (not only 429). Treat those as\n * transient so the backoff/retry path applies; a genuine permission 403 (no\n * rate-limit reason) stays non-retryable.\n */\nfunction isRateLimit403(body: string): boolean {\n return /rateLimitExceeded|userRateLimitExceeded/i.test(body)\n}\n\nfunction parseRetryAfter(value: string | null): number | null {\n if (!value) return null\n const asNumber = Number(value)\n if (Number.isFinite(asNumber) && asNumber >= 0) {\n return Math.min(asNumber * 1000, GMAIL_BACKOFF_CAP_MS)\n }\n const asDate = Date.parse(value)\n if (Number.isFinite(asDate)) {\n const delta = asDate - Date.now()\n if (delta > 0) return Math.min(delta, GMAIL_BACKOFF_CAP_MS)\n }\n return null\n}\n\nfunction computeBackoff(attempt: number): number {\n const raw = GMAIL_BACKOFF_BASE_MS * Math.pow(2, attempt)\n const jitter = Math.floor(Math.random() * 100)\n return Math.min(raw + jitter, GMAIL_BACKOFF_CAP_MS)\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\nexport class GmailApiError extends Error {\n readonly status: number\n readonly detail: string\n constructor(message: string, status: number, detail: string) {\n super(message)\n this.name = 'GmailApiError'\n this.status = status\n this.detail = detail\n }\n}\n\nfunction parseErrorMessage(text: string): string | null {\n if (!text) return null\n try {\n const parsed = JSON.parse(text) as { error?: { message?: string } | string }\n if (parsed && typeof parsed.error === 'object' && parsed.error && typeof parsed.error.message === 'string') {\n return parsed.error.message\n }\n if (typeof parsed?.error === 'string') return parsed.error\n } catch {\n /* fall through */\n }\n return text.length > 200 ? text.slice(0, 200) : text\n}\n\nlet cachedClient: GmailApiClient | null = null\n\nexport function getGmailApiClient(): GmailApiClient {\n if (!cachedClient) cachedClient = new FetchGmailApiClient()\n return cachedClient\n}\n\nexport function setGmailApiClient(client: GmailApiClient | null): void {\n cachedClient = client\n}\n\n/** Encode an RFC2822 message buffer to base64url as required by gmail.users.messages.send. */\nexport function encodeBase64Url(buffer: Buffer): string {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\n/** Decode a base64url payload (e.g. `gmail.users.messages.get?format=raw`) to a buffer. */\nexport function decodeBase64Url(value: string): Buffer {\n const normalized = value.replace(/-/g, '+').replace(/_/g, '/')\n const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4))\n return Buffer.from(normalized + padding, 'base64')\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Thin Gmail REST API wrapper. Same trade-off as `oauth.ts`: we use `fetch`\n * directly so the adapter doesn't import the `googleapis` SDK at runtime in\n * environments that don't need it (tests, build-only checks). Production code\n * paths still allow swapping to the SDK via `setGmailApiClient(...)` if a\n * downstream package wants the SDK's extra ergonomics.\n *\n * Only the endpoints the adapter actually calls are exposed:\n * - listHistory \u2192 gmail.users.history.list\n * - listMessages \u2192 gmail.users.messages.list (fallback when historyId expired)\n * - getMessageRaw \u2192 gmail.users.messages.get?format=raw\n * - sendRawMessage \u2192 gmail.users.messages.send\n * - getProfile \u2192 gmail.users.getProfile (health + initial historyId)\n * - deleteMessage \u2192 gmail.users.messages.trash (move to trash; matches `deleteMessage: true` capability)\n */\n\nconst GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1'\n\nexport interface GmailApiAuth {\n accessToken: string\n}\n\nexport interface GmailHistoryListInput {\n startHistoryId: string\n /** Optional page token for paging through history results. */\n pageToken?: string\n /** Optional label filter; defaults to INBOX-only changes. */\n labelId?: string\n}\n\nexport interface GmailHistoryRecord {\n id: string\n messagesAdded?: Array<{ message: { id: string; threadId: string; labelIds?: string[] } }>\n messagesDeleted?: Array<{ message: { id: string; threadId: string } }>\n labelsAdded?: Array<{ message: { id: string; threadId: string }; labelIds: string[] }>\n labelsRemoved?: Array<{ message: { id: string; threadId: string }; labelIds: string[] }>\n}\n\nexport interface GmailHistoryListResponse {\n history?: GmailHistoryRecord[]\n nextPageToken?: string\n historyId: string\n}\n\nexport interface GmailMessagesListInput {\n query?: string\n labelIds?: string[]\n pageToken?: string\n maxResults?: number\n}\n\nexport interface GmailMessagesListResponse {\n messages?: Array<{ id: string; threadId: string }>\n nextPageToken?: string\n resultSizeEstimate?: number\n}\n\nexport interface GmailGetMessageRawResponse {\n id: string\n threadId: string\n labelIds?: string[]\n /** Base64URL-encoded RFC2822 message. */\n raw: string\n internalDate?: string\n sizeEstimate?: number\n}\n\nexport interface GmailSendRawInput {\n /** Base64URL-encoded RFC2822 message body. */\n rawBase64Url: string\n /** Optional thread to attach to. */\n threadId?: string\n}\n\nexport interface GmailSendResponse {\n id: string\n threadId: string\n labelIds?: string[]\n}\n\nexport interface GmailProfileResponse {\n emailAddress: string\n messagesTotal?: number\n threadsTotal?: number\n historyId: string\n}\n\nexport interface GmailWatchInput {\n /** Fully-qualified Pub/Sub topic, e.g. `projects/myproj/topics/gmail-inbound`. */\n topicName: string\n /** Defaults to `['INBOX']` so only inbox changes generate notifications. */\n labelIds?: string[]\n /** `include` (default) or `exclude`. */\n labelFilterAction?: 'include' | 'exclude'\n}\n\nexport interface GmailWatchResponse {\n historyId: string\n /** Watch expiration timestamp, ms since epoch. Gmail caps at ~7 days. */\n expiration: string\n}\n\nexport interface GmailApiClient {\n listHistory(auth: GmailApiAuth, input: GmailHistoryListInput): Promise<GmailHistoryListResponse>\n listMessages(auth: GmailApiAuth, input: GmailMessagesListInput): Promise<GmailMessagesListResponse>\n getMessageRaw(auth: GmailApiAuth, messageId: string): Promise<GmailGetMessageRawResponse>\n sendRawMessage(auth: GmailApiAuth, input: GmailSendRawInput): Promise<GmailSendResponse>\n getProfile(auth: GmailApiAuth): Promise<GmailProfileResponse>\n trashMessage(auth: GmailApiAuth, messageId: string): Promise<void>\n /** Spec C \u2014 `gmail.users.watch` registers a Pub/Sub topic for push delivery. */\n watchInbox(auth: GmailApiAuth, input: GmailWatchInput): Promise<GmailWatchResponse>\n /** Spec C \u2014 `gmail.users.stop` tears down the Pub/Sub registration. */\n stopWatch(auth: GmailApiAuth): Promise<void>\n}\n\nconst GMAIL_MAX_RETRIES = 3\nconst GMAIL_BACKOFF_BASE_MS = 500\nconst GMAIL_BACKOFF_CAP_MS = 8_000\nconst GMAIL_DEFAULT_REQUEST_TIMEOUT_MS = 30_000\n\nfunction resolveGmailRequestTimeoutMs(): number {\n const fromEnv = Number.parseInt(process.env.OM_CHANNEL_GMAIL_REQUEST_TIMEOUT_MS ?? '', 10)\n return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : GMAIL_DEFAULT_REQUEST_TIMEOUT_MS\n}\n\nclass FetchGmailApiClient implements GmailApiClient {\n async listHistory(auth: GmailApiAuth, input: GmailHistoryListInput): Promise<GmailHistoryListResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/history`)\n url.searchParams.set('startHistoryId', input.startHistoryId)\n if (input.pageToken) url.searchParams.set('pageToken', input.pageToken)\n url.searchParams.set('labelId', input.labelId ?? 'INBOX')\n url.searchParams.set('historyTypes', 'messageAdded')\n return this.requestJson<GmailHistoryListResponse>(auth, url, 'GET')\n }\n\n async listMessages(auth: GmailApiAuth, input: GmailMessagesListInput): Promise<GmailMessagesListResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/messages`)\n if (input.query) url.searchParams.set('q', input.query)\n for (const label of input.labelIds ?? []) url.searchParams.append('labelIds', label)\n if (input.pageToken) url.searchParams.set('pageToken', input.pageToken)\n if (input.maxResults) url.searchParams.set('maxResults', String(input.maxResults))\n return this.requestJson<GmailMessagesListResponse>(auth, url, 'GET')\n }\n\n async getMessageRaw(auth: GmailApiAuth, messageId: string): Promise<GmailGetMessageRawResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/messages/${encodeURIComponent(messageId)}`)\n url.searchParams.set('format', 'raw')\n return this.requestJson<GmailGetMessageRawResponse>(auth, url, 'GET')\n }\n\n async sendRawMessage(auth: GmailApiAuth, input: GmailSendRawInput): Promise<GmailSendResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/messages/send`)\n return this.requestJson<GmailSendResponse>(auth, url, 'POST', {\n raw: input.rawBase64Url,\n threadId: input.threadId,\n })\n }\n\n async getProfile(auth: GmailApiAuth): Promise<GmailProfileResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/profile`)\n return this.requestJson<GmailProfileResponse>(auth, url, 'GET')\n }\n\n async trashMessage(auth: GmailApiAuth, messageId: string): Promise<void> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/messages/${encodeURIComponent(messageId)}/trash`)\n await this.requestJson(auth, url, 'POST')\n }\n\n async watchInbox(auth: GmailApiAuth, input: GmailWatchInput): Promise<GmailWatchResponse> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/watch`)\n return this.requestJson<GmailWatchResponse>(auth, url, 'POST', {\n topicName: input.topicName,\n labelIds: input.labelIds ?? ['INBOX'],\n labelFilterAction: input.labelFilterAction ?? 'include',\n })\n }\n\n async stopWatch(auth: GmailApiAuth): Promise<void> {\n const url = new URL(`${GMAIL_API_BASE}/users/me/stop`)\n await this.requestJson(auth, url, 'POST')\n }\n\n private async requestJson<T>(auth: GmailApiAuth, url: URL, method: 'GET' | 'POST', body?: unknown): Promise<T> {\n const headers: Record<string, string> = {\n Authorization: `Bearer ${auth.accessToken}`,\n }\n let payload: BodyInit | undefined\n if (body !== undefined) {\n headers['Content-Type'] = 'application/json'\n payload = JSON.stringify(body)\n }\n // Retry transient failures (429, 5xx) with exponential backoff + jitter,\n // honoring `Retry-After` when present. Per Gmail API docs at\n // https://developers.google.com/gmail/api/guides/handle-errors this is the\n // documented mitigation for rate-limit + server-side transient errors.\n let attempt = 0\n let lastError: GmailApiError | null = null\n while (attempt <= GMAIL_MAX_RETRIES) {\n let res: Response\n try {\n res = await fetch(url.toString(), {\n method,\n headers,\n body: payload,\n // Bound each attempt so a stalled connection fails fast instead of\n // hanging on undici's multi-minute default and pinning the worker slot.\n signal: AbortSignal.timeout(resolveGmailRequestTimeoutMs()),\n })\n } catch (err) {\n // A timed-out/aborted connection is transient \u2014 let the bounded retry\n // loop retry it rather than propagating a raw error. `AbortSignal.timeout`\n // rejects fetch with a `TimeoutError` DOMException; an externally-aborted\n // request surfaces as `AbortError`. Match on the `name` field (not\n // `instanceof Error`, since DOMException does not subclass Error across\n // realms) \u2014 treat both as transient.\n const errName = (err as { name?: unknown } | null)?.name\n const aborted = errName === 'TimeoutError' || errName === 'AbortError'\n if (!aborted) throw err\n const timeoutError = new GmailApiError(\n `Gmail API ${method} ${url.pathname} timed out`,\n 599,\n 'request timed out',\n )\n if (attempt === GMAIL_MAX_RETRIES) throw timeoutError\n lastError = timeoutError\n await sleep(computeBackoff(attempt))\n attempt += 1\n continue\n }\n const text = await res.text()\n if (res.ok) {\n if (!text) return undefined as unknown as T\n return JSON.parse(text) as T\n }\n const detail = parseErrorMessage(text) ?? `${res.status} ${res.statusText}`\n const apiError = new GmailApiError(\n `Gmail API ${method} ${url.pathname} failed: ${detail}`,\n res.status,\n detail,\n )\n const transient =\n res.status === 429 ||\n (res.status >= 500 && res.status < 600) ||\n // Gmail signals per-user/project quota exhaustion with HTTP 403 +\n // `rateLimitExceeded`/`userRateLimitExceeded` (not only 429).\n (res.status === 403 && isRateLimit403(text))\n if (!transient || attempt === GMAIL_MAX_RETRIES) {\n throw apiError\n }\n lastError = apiError\n const retryAfterHeader = res.headers.get('retry-after')\n const waitMs =\n parseRetryAfter(retryAfterHeader) ?? computeBackoff(attempt)\n await sleep(waitMs)\n attempt += 1\n }\n throw lastError ?? new GmailApiError(`Gmail API ${method} ${url.pathname} exhausted retries`, 599, 'retries exhausted')\n }\n}\n\n/**\n * Gmail signals quota exhaustion with HTTP 403 + an error reason of\n * `rateLimitExceeded` / `userRateLimitExceeded` (not only 429). Treat those as\n * transient so the backoff/retry path applies; a genuine permission 403 (no\n * rate-limit reason) stays non-retryable.\n */\nfunction isRateLimit403(body: string): boolean {\n return /rateLimitExceeded|userRateLimitExceeded/i.test(body)\n}\n\nfunction parseRetryAfter(value: string | null): number | null {\n if (!value) return null\n const asNumber = Number(value)\n if (Number.isFinite(asNumber) && asNumber >= 0) {\n return Math.min(asNumber * 1000, GMAIL_BACKOFF_CAP_MS)\n }\n const asDate = Date.parse(value)\n if (Number.isFinite(asDate)) {\n const delta = asDate - Date.now()\n if (delta > 0) return Math.min(delta, GMAIL_BACKOFF_CAP_MS)\n }\n return null\n}\n\nfunction computeBackoff(attempt: number): number {\n const raw = GMAIL_BACKOFF_BASE_MS * Math.pow(2, attempt)\n const jitter = Math.floor(Math.random() * 100)\n return Math.min(raw + jitter, GMAIL_BACKOFF_CAP_MS)\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\nexport class GmailApiError extends Error {\n readonly status: number\n readonly detail: string\n constructor(message: string, status: number, detail: string) {\n super(message)\n this.name = 'GmailApiError'\n this.status = status\n this.detail = detail\n }\n}\n\nfunction parseErrorMessage(text: string): string | null {\n if (!text) return null\n try {\n const parsed = JSON.parse(text) as { error?: { message?: string } | string }\n if (parsed && typeof parsed.error === 'object' && parsed.error && typeof parsed.error.message === 'string') {\n return parsed.error.message\n }\n if (typeof parsed?.error === 'string') return parsed.error\n } catch {\n /* fall through */\n }\n return text.length > 200 ? text.slice(0, 200) : text\n}\n\nlet cachedClient: GmailApiClient | null = null\n\nexport function getGmailApiClient(): GmailApiClient {\n if (!cachedClient) cachedClient = new FetchGmailApiClient()\n return cachedClient\n}\n\nexport function setGmailApiClient(client: GmailApiClient | null): void {\n cachedClient = client\n}\n\n/** Encode an RFC2822 message buffer to base64url as required by gmail.users.messages.send. */\nexport function encodeBase64Url(buffer: Buffer): string {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\n/** Decode a base64url payload (e.g. `gmail.users.messages.get?format=raw`) to a buffer. */\nexport function decodeBase64Url(value: string): Buffer {\n const normalized = value.replace(/-/g, '+').replace(/_/g, '/')\n const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4))\n return Buffer.from(normalized + padding, 'base64')\n}\n"],
5
+ "mappings": "AAgBA,MAAM,iBAAiB;AAmGvB,MAAM,oBAAoB;AAC1B,MAAM,wBAAwB;AAC9B,MAAM,uBAAuB;AAC7B,MAAM,mCAAmC;AAEzC,SAAS,+BAAuC;AAC9C,QAAM,UAAU,OAAO,SAAS,QAAQ,IAAI,uCAAuC,IAAI,EAAE;AACzF,SAAO,OAAO,SAAS,OAAO,KAAK,UAAU,IAAI,UAAU;AAC7D;AAEA,MAAM,oBAA8C;AAAA,EAClD,MAAM,YAAY,MAAoB,OAAiE;AACrG,UAAM,MAAM,IAAI,IAAI,GAAG,cAAc,mBAAmB;AACxD,QAAI,aAAa,IAAI,kBAAkB,MAAM,cAAc;AAC3D,QAAI,MAAM,UAAW,KAAI,aAAa,IAAI,aAAa,MAAM,SAAS;AACtE,QAAI,aAAa,IAAI,WAAW,MAAM,WAAW,OAAO;AACxD,QAAI,aAAa,IAAI,gBAAgB,cAAc;AACnD,WAAO,KAAK,YAAsC,MAAM,KAAK,KAAK;AAAA,EACpE;AAAA,EAEA,MAAM,aAAa,MAAoB,OAAmE;AACxG,UAAM,MAAM,IAAI,IAAI,GAAG,cAAc,oBAAoB;AACzD,QAAI,MAAM,MAAO,KAAI,aAAa,IAAI,KAAK,MAAM,KAAK;AACtD,eAAW,SAAS,MAAM,YAAY,CAAC,EAAG,KAAI,aAAa,OAAO,YAAY,KAAK;AACnF,QAAI,MAAM,UAAW,KAAI,aAAa,IAAI,aAAa,MAAM,SAAS;AACtE,QAAI,MAAM,WAAY,KAAI,aAAa,IAAI,cAAc,OAAO,MAAM,UAAU,CAAC;AACjF,WAAO,KAAK,YAAuC,MAAM,KAAK,KAAK;AAAA,EACrE;AAAA,EAEA,MAAM,cAAc,MAAoB,WAAwD;AAC9F,UAAM,MAAM,IAAI,IAAI,GAAG,cAAc,sBAAsB,mBAAmB,SAAS,CAAC,EAAE;AAC1F,QAAI,aAAa,IAAI,UAAU,KAAK;AACpC,WAAO,KAAK,YAAwC,MAAM,KAAK,KAAK;AAAA,EACtE;AAAA,EAEA,MAAM,eAAe,MAAoB,OAAsD;AAC7F,UAAM,MAAM,IAAI,IAAI,GAAG,cAAc,yBAAyB;AAC9D,WAAO,KAAK,YAA+B,MAAM,KAAK,QAAQ;AAAA,MAC5D,KAAK,MAAM;AAAA,MACX,UAAU,MAAM;AAAA,IAClB,CAAC;AAAA,EACH;AAAA,EAEA,MAAM,WAAW,MAAmD;AAClE,UAAM,MAAM,IAAI,IAAI,GAAG,cAAc,mBAAmB;AACxD,WAAO,KAAK,YAAkC,MAAM,KAAK,KAAK;AAAA,EAChE;AAAA,EAEA,MAAM,aAAa,MAAoB,WAAkC;AACvE,UAAM,MAAM,IAAI,IAAI,GAAG,cAAc,sBAAsB,mBAAmB,SAAS,CAAC,QAAQ;AAChG,UAAM,KAAK,YAAY,MAAM,KAAK,MAAM;AAAA,EAC1C;AAAA,EAEA,MAAM,WAAW,MAAoB,OAAqD;AACxF,UAAM,MAAM,IAAI,IAAI,GAAG,cAAc,iBAAiB;AACtD,WAAO,KAAK,YAAgC,MAAM,KAAK,QAAQ;AAAA,MAC7D,WAAW,MAAM;AAAA,MACjB,UAAU,MAAM,YAAY,CAAC,OAAO;AAAA,MACpC,mBAAmB,MAAM,qBAAqB;AAAA,IAChD,CAAC;AAAA,EACH;AAAA,EAEA,MAAM,UAAU,MAAmC;AACjD,UAAM,MAAM,IAAI,IAAI,GAAG,cAAc,gBAAgB;AACrD,UAAM,KAAK,YAAY,MAAM,KAAK,MAAM;AAAA,EAC1C;AAAA,EAEA,MAAc,YAAe,MAAoB,KAAU,QAAwB,MAA4B;AAC7G,UAAM,UAAkC;AAAA,MACtC,eAAe,UAAU,KAAK,WAAW;AAAA,IAC3C;AACA,QAAI;AACJ,QAAI,SAAS,QAAW;AACtB,cAAQ,cAAc,IAAI;AAC1B,gBAAU,KAAK,UAAU,IAAI;AAAA,IAC/B;AAKA,QAAI,UAAU;AACd,QAAI,YAAkC;AACtC,WAAO,WAAW,mBAAmB;AACnC,UAAI;AACJ,UAAI;AACF,cAAM,MAAM,MAAM,IAAI,SAAS,GAAG;AAAA,UAChC;AAAA,UACA;AAAA,UACA,MAAM;AAAA;AAAA;AAAA,UAGN,QAAQ,YAAY,QAAQ,6BAA6B,CAAC;AAAA,QAC5D,CAAC;AAAA,MACH,SAAS,KAAK;AAOZ,cAAM,UAAW,KAAmC;AACpD,cAAM,UAAU,YAAY,kBAAkB,YAAY;AAC1D,YAAI,CAAC,QAAS,OAAM;AACpB,cAAM,eAAe,IAAI;AAAA,UACvB,aAAa,MAAM,IAAI,IAAI,QAAQ;AAAA,UACnC;AAAA,UACA;AAAA,QACF;AACA,YAAI,YAAY,kBAAmB,OAAM;AACzC,oBAAY;AACZ,cAAM,MAAM,eAAe,OAAO,CAAC;AACnC,mBAAW;AACX;AAAA,MACF;AACA,YAAM,OAAO,MAAM,IAAI,KAAK;AAC5B,UAAI,IAAI,IAAI;AACV,YAAI,CAAC,KAAM,QAAO;AAClB,eAAO,KAAK,MAAM,IAAI;AAAA,MACxB;AACA,YAAM,SAAS,kBAAkB,IAAI,KAAK,GAAG,IAAI,MAAM,IAAI,IAAI,UAAU;AACzE,YAAM,WAAW,IAAI;AAAA,QACnB,aAAa,MAAM,IAAI,IAAI,QAAQ,YAAY,MAAM;AAAA,QACrD,IAAI;AAAA,QACJ;AAAA,MACF;AACA,YAAM,YACJ,IAAI,WAAW,OACd,IAAI,UAAU,OAAO,IAAI,SAAS;AAAA;AAAA,MAGlC,IAAI,WAAW,OAAO,eAAe,IAAI;AAC5C,UAAI,CAAC,aAAa,YAAY,mBAAmB;AAC/C,cAAM;AAAA,MACR;AACA,kBAAY;AACZ,YAAM,mBAAmB,IAAI,QAAQ,IAAI,aAAa;AACtD,YAAM,SACJ,gBAAgB,gBAAgB,KAAK,eAAe,OAAO;AAC7D,YAAM,MAAM,MAAM;AAClB,iBAAW;AAAA,IACb;AACA,UAAM,aAAa,IAAI,cAAc,aAAa,MAAM,IAAI,IAAI,QAAQ,sBAAsB,KAAK,mBAAmB;AAAA,EACxH;AACF;AAQA,SAAS,eAAe,MAAuB;AAC7C,SAAO,2CAA2C,KAAK,IAAI;AAC7D;AAEA,SAAS,gBAAgB,OAAqC;AAC5D,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,WAAW,OAAO,KAAK;AAC7B,MAAI,OAAO,SAAS,QAAQ,KAAK,YAAY,GAAG;AAC9C,WAAO,KAAK,IAAI,WAAW,KAAM,oBAAoB;AAAA,EACvD;AACA,QAAM,SAAS,KAAK,MAAM,KAAK;AAC/B,MAAI,OAAO,SAAS,MAAM,GAAG;AAC3B,UAAM,QAAQ,SAAS,KAAK,IAAI;AAChC,QAAI,QAAQ,EAAG,QAAO,KAAK,IAAI,OAAO,oBAAoB;AAAA,EAC5D;AACA,SAAO;AACT;AAEA,SAAS,eAAe,SAAyB;AAC/C,QAAM,MAAM,wBAAwB,KAAK,IAAI,GAAG,OAAO;AACvD,QAAM,SAAS,KAAK,MAAM,KAAK,OAAO,IAAI,GAAG;AAC7C,SAAO,KAAK,IAAI,MAAM,QAAQ,oBAAoB;AACpD;AAEA,SAAS,MAAM,IAA2B;AACxC,SAAO,IAAI,QAAQ,CAAC,YAAY,WAAW,SAAS,EAAE,CAAC;AACzD;AAEO,MAAM,sBAAsB,MAAM;AAAA,EAGvC,YAAY,SAAiB,QAAgB,QAAgB;AAC3D,UAAM,OAAO;AACb,SAAK,OAAO;AACZ,SAAK,SAAS;AACd,SAAK,SAAS;AAAA,EAChB;AACF;AAEA,SAAS,kBAAkB,MAA6B;AACtD,MAAI,CAAC,KAAM,QAAO;AAClB,MAAI;AACF,UAAM,SAAS,KAAK,MAAM,IAAI;AAC9B,QAAI,UAAU,OAAO,OAAO,UAAU,YAAY,OAAO,SAAS,OAAO,OAAO,MAAM,YAAY,UAAU;AAC1G,aAAO,OAAO,MAAM;AAAA,IACtB;AACA,QAAI,OAAO,QAAQ,UAAU,SAAU,QAAO,OAAO;AAAA,EACvD,QAAQ;AAAA,EAER;AACA,SAAO,KAAK,SAAS,MAAM,KAAK,MAAM,GAAG,GAAG,IAAI;AAClD;AAEA,IAAI,eAAsC;AAEnC,SAAS,oBAAoC;AAClD,MAAI,CAAC,aAAc,gBAAe,IAAI,oBAAoB;AAC1D,SAAO;AACT;AAEO,SAAS,kBAAkB,QAAqC;AACrE,iBAAe;AACjB;AAGO,SAAS,gBAAgB,QAAwB;AACtD,SAAO,OAAO,SAAS,QAAQ,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AAC5F;AAGO,SAAS,gBAAgB,OAAuB;AACrD,QAAM,aAAa,MAAM,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG;AAC7D,QAAM,UAAU,WAAW,SAAS,MAAM,IAAI,KAAK,IAAI,OAAO,IAAK,WAAW,SAAS,CAAE;AACzF,SAAO,OAAO,KAAK,aAAa,SAAS,QAAQ;AACnD;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/channel-gmail",
3
- "version": "0.6.6-develop.5523.1.e223ca1915",
3
+ "version": "0.6.6-develop.5531.1.ab1959dfae",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -61,19 +61,19 @@
61
61
  }
62
62
  },
63
63
  "dependencies": {
64
- "@open-mercato/core": "0.6.6-develop.5523.1.e223ca1915",
65
- "@open-mercato/ui": "0.6.6-develop.5523.1.e223ca1915",
64
+ "@open-mercato/core": "0.6.6-develop.5531.1.ab1959dfae",
65
+ "@open-mercato/ui": "0.6.6-develop.5531.1.ab1959dfae",
66
66
  "@types/mailparser": "^3.4.5",
67
67
  "mailparser": "^3.7.1"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@mikro-orm/postgresql": "^7.0.14",
71
- "@open-mercato/shared": "0.6.6-develop.5523.1.e223ca1915",
71
+ "@open-mercato/shared": "0.6.6-develop.5531.1.ab1959dfae",
72
72
  "react": "^19.0.0",
73
73
  "react-dom": "^19.0.0"
74
74
  },
75
75
  "devDependencies": {
76
- "@open-mercato/shared": "0.6.6-develop.5523.1.e223ca1915",
76
+ "@open-mercato/shared": "0.6.6-develop.5531.1.ab1959dfae",
77
77
  "@types/jest": "^30.0.0",
78
78
  "@types/react": "^19.2.17",
79
79
  "@types/react-dom": "^19.2.3",
@@ -206,4 +206,78 @@ describe('FetchGmailApiClient.requestJson retry/backoff', () => {
206
206
  expect(calls).toBe(1)
207
207
  expect(capturedDelays).toEqual([])
208
208
  })
209
+
210
+ it('attaches an AbortSignal to each request so a stalled connection cannot hang the worker (issue #2976)', async () => {
211
+ let capturedSignal: unknown
212
+ globalThis.fetch = ((_url: string, init?: RequestInit) => {
213
+ capturedSignal = init?.signal
214
+ return Promise.resolve(
215
+ fakeResponse({ status: 200, statusText: 'OK', body: JSON.stringify({ emailAddress: 'a@gmail.com', historyId: '1' }) }),
216
+ )
217
+ }) as unknown as typeof globalThis.fetch
218
+
219
+ await getGmailApiClient().getProfile({ accessToken: 'token' })
220
+
221
+ expect(capturedSignal).toBeInstanceOf(AbortSignal)
222
+ })
223
+
224
+ it('treats a timed-out (TimeoutError) fetch as transient and retries it (issue #2976)', async () => {
225
+ Math.random = () => 0
226
+ let calls = 0
227
+ globalThis.fetch = (() => {
228
+ calls += 1
229
+ if (calls === 1) {
230
+ // `AbortSignal.timeout()` rejects fetch with a `TimeoutError` DOMException,
231
+ // not an `AbortError` — exercise the real production failure shape.
232
+ return Promise.reject(new DOMException('The operation timed out', 'TimeoutError'))
233
+ }
234
+ return Promise.resolve(
235
+ fakeResponse({ status: 200, statusText: 'OK', body: JSON.stringify({ emailAddress: 'a@gmail.com', historyId: '5' }) }),
236
+ )
237
+ }) as unknown as typeof globalThis.fetch
238
+
239
+ const profile = await getGmailApiClient().getProfile({ accessToken: 'token' })
240
+
241
+ expect(calls).toBe(2)
242
+ expect(profile.historyId).toBe('5')
243
+ // computeBackoff(0) = 500ms (jitter stripped) — the timeout took the retry path.
244
+ expect(capturedDelays).toEqual([500])
245
+ })
246
+
247
+ it('also retries an externally-aborted (AbortError) fetch (issue #2976)', async () => {
248
+ Math.random = () => 0
249
+ let calls = 0
250
+ globalThis.fetch = (() => {
251
+ calls += 1
252
+ if (calls === 1) return Promise.reject(new DOMException('The operation was aborted', 'AbortError'))
253
+ return Promise.resolve(
254
+ fakeResponse({ status: 200, statusText: 'OK', body: JSON.stringify({ emailAddress: 'a@gmail.com', historyId: '7' }) }),
255
+ )
256
+ }) as unknown as typeof globalThis.fetch
257
+
258
+ const profile = await getGmailApiClient().getProfile({ accessToken: 'token' })
259
+
260
+ expect(calls).toBe(2)
261
+ expect(profile.historyId).toBe('7')
262
+ expect(capturedDelays).toEqual([500])
263
+ })
264
+
265
+ it('throws a GmailApiError after timeouts exhaust the retry budget (issue #2976)', async () => {
266
+ Math.random = () => 0
267
+ let calls = 0
268
+ globalThis.fetch = (() => {
269
+ calls += 1
270
+ return Promise.reject(new DOMException('The operation timed out', 'TimeoutError'))
271
+ }) as unknown as typeof globalThis.fetch
272
+
273
+ const thrown = await getGmailApiClient()
274
+ .getProfile({ accessToken: 'token' })
275
+ .catch((error: unknown) => error)
276
+
277
+ expect(thrown).toBeInstanceOf(GmailApiError)
278
+ expect((thrown as GmailApiError).status).toBe(599)
279
+ // 1 initial + 3 retries = 4 attempts; backoff fired on the first 3.
280
+ expect(calls).toBe(4)
281
+ expect(capturedDelays).toEqual([500, 1000, 2000])
282
+ })
209
283
  })
@@ -116,6 +116,12 @@ export interface GmailApiClient {
116
116
  const GMAIL_MAX_RETRIES = 3
117
117
  const GMAIL_BACKOFF_BASE_MS = 500
118
118
  const GMAIL_BACKOFF_CAP_MS = 8_000
119
+ const GMAIL_DEFAULT_REQUEST_TIMEOUT_MS = 30_000
120
+
121
+ function resolveGmailRequestTimeoutMs(): number {
122
+ const fromEnv = Number.parseInt(process.env.OM_CHANNEL_GMAIL_REQUEST_TIMEOUT_MS ?? '', 10)
123
+ return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : GMAIL_DEFAULT_REQUEST_TIMEOUT_MS
124
+ }
119
125
 
120
126
  class FetchGmailApiClient implements GmailApiClient {
121
127
  async listHistory(auth: GmailApiAuth, input: GmailHistoryListInput): Promise<GmailHistoryListResponse> {
@@ -190,7 +196,37 @@ class FetchGmailApiClient implements GmailApiClient {
190
196
  let attempt = 0
191
197
  let lastError: GmailApiError | null = null
192
198
  while (attempt <= GMAIL_MAX_RETRIES) {
193
- const res = await fetch(url.toString(), { method, headers, body: payload })
199
+ let res: Response
200
+ try {
201
+ res = await fetch(url.toString(), {
202
+ method,
203
+ headers,
204
+ body: payload,
205
+ // Bound each attempt so a stalled connection fails fast instead of
206
+ // hanging on undici's multi-minute default and pinning the worker slot.
207
+ signal: AbortSignal.timeout(resolveGmailRequestTimeoutMs()),
208
+ })
209
+ } catch (err) {
210
+ // A timed-out/aborted connection is transient — let the bounded retry
211
+ // loop retry it rather than propagating a raw error. `AbortSignal.timeout`
212
+ // rejects fetch with a `TimeoutError` DOMException; an externally-aborted
213
+ // request surfaces as `AbortError`. Match on the `name` field (not
214
+ // `instanceof Error`, since DOMException does not subclass Error across
215
+ // realms) — treat both as transient.
216
+ const errName = (err as { name?: unknown } | null)?.name
217
+ const aborted = errName === 'TimeoutError' || errName === 'AbortError'
218
+ if (!aborted) throw err
219
+ const timeoutError = new GmailApiError(
220
+ `Gmail API ${method} ${url.pathname} timed out`,
221
+ 599,
222
+ 'request timed out',
223
+ )
224
+ if (attempt === GMAIL_MAX_RETRIES) throw timeoutError
225
+ lastError = timeoutError
226
+ await sleep(computeBackoff(attempt))
227
+ attempt += 1
228
+ continue
229
+ }
194
230
  const text = await res.text()
195
231
  if (res.ok) {
196
232
  if (!text) return undefined as unknown as T