From df2c29c68829f8673300b72a577c1170ad82325e Mon Sep 17 00:00:00 2001 From: hugo-ccabral Date: Fri, 24 Jul 2026 11:17:11 -0300 Subject: [PATCH 1/2] fix: default timeout on outbound fetch calls Mirrors decocms/blocks#392. Nothing bounded how long an outbound fetch could hang, letting a stuck upstream connection pin request context in memory until the runtime kills the isolate. Adds commerce/utils/fetchTimeout.ts (AbortSignal.timeout composed with any caller signal via AbortSignal.any, 10s default) and wires it into VTEX, Magento, Resend, Shopify, and the Google Fonts loader's raw fetch call sites. This repo pins @decocms/start@6.6.1, which predates the equivalent export in blocks, so the helper is duplicated here for now -- replace with a re-export from @decocms/start/sdk/fetchTimeout once that dependency is bumped past the version shipping it. Co-Authored-By: Claude Sonnet 5 --- commerce/utils/fetchTimeout.ts | 56 ++++++++++++++++++++++++++++ magento/client.ts | 6 ++- resend/__tests__/send.test.ts | 28 ++++++++------ resend/actions/send.ts | 5 ++- shopify/utils/graphql.ts | 3 +- vtex/client.ts | 3 +- vtex/utils/fetch.ts | 6 ++- website/loaders/fonts/googleFonts.ts | 7 +++- 8 files changed, 95 insertions(+), 19 deletions(-) create mode 100644 commerce/utils/fetchTimeout.ts diff --git a/commerce/utils/fetchTimeout.ts b/commerce/utils/fetchTimeout.ts new file mode 100644 index 0000000..b976f79 --- /dev/null +++ b/commerce/utils/fetchTimeout.ts @@ -0,0 +1,56 @@ +/** + * Default network-level timeout for outbound `fetch()` calls. + * + * Mirrors `@decocms/blocks/sdk/fetchTimeout` in the deco-start monorepo — + * duplicated locally because this repo pins `@decocms/start@6.6.1`, which + * predates that export. Replace this file with a re-export from + * `@decocms/start/sdk/fetchTimeout` once the dependency is bumped past the + * version that ships it, and delete the copy. + * + * Background: nothing here previously bounded how long an outbound fetch + * could hang. A hung upstream connection (VTEX/CDN holding a TCP connection + * open) pins the request context in memory until the runtime kills the + * isolate — see `apps-vtex/utils/fetchCache.ts`'s inflight-timeout comment + * for the production incident this class of bug caused. This module fixes + * the root cause: actually abort the request via `AbortSignal.timeout`, + * composed with any caller-supplied signal so callers that already do their + * own cancellation aren't overridden. + */ + +/** Default per-request timeout for outbound fetch calls. */ +export const DEFAULT_FETCH_TIMEOUT_MS = 10_000; + +/** + * Combine a caller-supplied `AbortSignal` (if any) with a timeout signal, so + * neither cancellation source is lost. Pass `timeoutMs <= 0` or non-finite + * to opt out of the timeout entirely (e.g. long-lived streaming requests) + * while still honoring the caller's own signal. + */ +export function withTimeoutSignal( + signal: AbortSignal | undefined | null, + timeoutMs: number, +): AbortSignal | undefined { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return signal ?? undefined; + const timeoutSignal = AbortSignal.timeout(timeoutMs); + return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; +} + +/** + * Wrap a `fetch` implementation so every call is aborted after `timeoutMs` + * unless it settles first. Use directly at ad-hoc fetch call sites that + * don't go through an instrumented client. + * + * When `baseFetch` is omitted, `globalThis.fetch` is resolved on every call + * (not captured once at wrap time) so tests that swap `globalThis.fetch` + * with a mock after this module loads still get intercepted. + */ +export function withFetchTimeout( + baseFetch?: typeof fetch, + timeoutMs: number = DEFAULT_FETCH_TIMEOUT_MS, +): typeof fetch { + return (input, init) => + (baseFetch ?? globalThis.fetch)(input, { + ...init, + signal: withTimeoutSignal(init?.signal, timeoutMs), + }); +} diff --git a/magento/client.ts b/magento/client.ts index 5496828..bcc1994 100644 --- a/magento/client.ts +++ b/magento/client.ts @@ -15,6 +15,10 @@ * Magento has consistent muscle memory. */ +import { withFetchTimeout } from "../commerce/utils/fetchTimeout"; + +const timeoutFetch = withFetchTimeout(); + // --------------------------------------------------------------------------- // Config shapes // --------------------------------------------------------------------------- @@ -224,5 +228,5 @@ export function magentoFetch(path: string, opts: MagentoFetchOpts = {}): Promise // clarity at the call site. const sameOrigin = target.origin === baseUrl.origin; - return fetch(target, { ...opts, headers: buildHeaders(opts, c, sameOrigin) }); + return timeoutFetch(target, { ...opts, headers: buildHeaders(opts, c, sameOrigin) }); } diff --git a/resend/__tests__/send.test.ts b/resend/__tests__/send.test.ts index b57637a..e02c5ff 100644 --- a/resend/__tests__/send.test.ts +++ b/resend/__tests__/send.test.ts @@ -35,19 +35,23 @@ describe("sendEmail", () => { expect(result.data).toEqual({ id: "email_abc123" }); expect(result.error).toBeNull(); - expect(fetchSpy).toHaveBeenCalledWith("https://api.resend.com/emails", { - method: "POST", - headers: { - Authorization: "Bearer re_test_123", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - from: "Test ", - to: "user@example.com", - subject: "Hello", - html: "

World

", + expect(fetchSpy).toHaveBeenCalledWith( + "https://api.resend.com/emails", + expect.objectContaining({ + method: "POST", + headers: { + Authorization: "Bearer re_test_123", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: "Test ", + to: "user@example.com", + subject: "Hello", + html: "

World

", + }), + signal: expect.any(AbortSignal), }), - }); + ); }); it("falls back to defaults when fields are omitted", async () => { diff --git a/resend/actions/send.ts b/resend/actions/send.ts index c272725..fca15b7 100644 --- a/resend/actions/send.ts +++ b/resend/actions/send.ts @@ -1,6 +1,9 @@ +import { withFetchTimeout } from "../../commerce/utils/fetchTimeout"; import { getResendConfig } from "../client"; import type { CreateEmailOptions, CreateEmailResponse } from "../types"; +const timeoutFetch = withFetchTimeout(); + /** * Send an email via Resend API. * @@ -32,7 +35,7 @@ export async function sendEmail( ...(payload.headers && { headers: payload.headers }), }; - const response = await fetch("https://api.resend.com/emails", { + const response = await timeoutFetch("https://api.resend.com/emails", { method: "POST", headers: { Authorization: `Bearer ${config.apiKey}`, diff --git a/shopify/utils/graphql.ts b/shopify/utils/graphql.ts index a0c9d63..076cb04 100644 --- a/shopify/utils/graphql.ts +++ b/shopify/utils/graphql.ts @@ -1,4 +1,5 @@ import type { InstrumentedFetchInit } from "@decocms/start/sdk/instrumentedFetch"; +import { withFetchTimeout } from "../../commerce/utils/fetchTimeout"; import { extractGraphqlOperationName } from "./graphqlOperationName"; export function gql(strings: TemplateStringsArray, ...values: unknown[]): string { @@ -24,7 +25,7 @@ export function createGraphqlClient( headers: Record, fetchFn?: typeof fetch, ): GraphQLClient { - const _fetch = fetchFn ?? globalThis.fetch; + const _fetch = fetchFn ?? withFetchTimeout(); return { async query( queryOrDef: string | QueryDefinition, diff --git a/vtex/client.ts b/vtex/client.ts index dde91a5..307e045 100644 --- a/vtex/client.ts +++ b/vtex/client.ts @@ -8,6 +8,7 @@ import type { InstrumentedFetchInit, } from "@decocms/start/sdk/instrumentedFetch"; import { RequestContext } from "@decocms/start/sdk/requestContext"; +import { withFetchTimeout } from "../commerce/utils/fetchTimeout"; import { sanitizeOutboundCookieHeader, warnDroppedCookies } from "./utils/cookieSanitizer"; import { type FetchCacheOptions, fetchWithCache } from "./utils/fetchCache"; import { ANONYMOUS_COOKIE, SESSION_COOKIE } from "./utils/intelligentSearch"; @@ -147,7 +148,7 @@ export interface VtexConfig { } let _config: VtexConfig | null = null; -let _fetch: typeof fetch | InstrumentedFetch = globalThis.fetch; +let _fetch: typeof fetch | InstrumentedFetch = withFetchTimeout(); export function configureVtex(config: VtexConfig) { _config = config; diff --git a/vtex/utils/fetch.ts b/vtex/utils/fetch.ts index 257cafa..8c08699 100644 --- a/vtex/utils/fetch.ts +++ b/vtex/utils/fetch.ts @@ -15,6 +15,10 @@ * the platform. */ +import { withFetchTimeout } from "../../commerce/utils/fetchTimeout"; + +const timeoutFetch = withFetchTimeout(); + type CachingMode = "stale-while-revalidate"; type DecoInit = { @@ -88,7 +92,7 @@ export async function fetchSafe( init?: DecoRequestInit, ): Promise { const sanitized = sanitizeUrl(input); - const response = await fetch(sanitized as RequestInfo, init); + const response = await timeoutFetch(sanitized as RequestInfo, init); if (!response.ok) { throw new HttpError(response); } diff --git a/website/loaders/fonts/googleFonts.ts b/website/loaders/fonts/googleFonts.ts index bf13488..583496e 100644 --- a/website/loaders/fonts/googleFonts.ts +++ b/website/loaders/fonts/googleFonts.ts @@ -1,5 +1,8 @@ +import { withFetchTimeout } from "../../../commerce/utils/fetchTimeout"; import type { Font } from "../../types"; +const timeoutFetch = withFetchTimeout(); + interface Props { fonts: GoogleFont[]; } @@ -94,13 +97,13 @@ const loader = async (props: Props): Promise => { }; const sheets = await Promise.all([ - fetch(url, { headers: OLD_BROWSER_KEY }) + timeoutFetch(url, { headers: OLD_BROWSER_KEY }) .then((res) => res.text()) .catch((e) => { logFontError("OLD_UA", url, e); return ""; }), - fetch(url, { headers: NEW_BROWSER_KEY }) + timeoutFetch(url, { headers: NEW_BROWSER_KEY }) .then((res) => res.text()) .catch((e) => { logFontError("NEW_UA", url, e); From cbf588eb349044dde80ad919d11d871ad40f97e1 Mon Sep 17 00:00:00 2001 From: hugo-ccabral Date: Fri, 24 Jul 2026 15:00:54 -0300 Subject: [PATCH 2/2] lint: ban bare fetch() in commerce packages to enforce the timeout wrapper A future dev can still bypass fetchSafe/vtexFetch/magentoFetch/etc. by typing bare fetch(), silently losing the timeout. Adds a Biome noRestrictedGlobals override banning the fetch global in vtex/magento/resend/shopify/website (excluding client-side hooks and tests), pointing at the sanctioned client. The ban also catches `typeof fetch` in type positions, so introduces a FetchFn alias (commerce/utils/fetchTimeout.ts) to replace those annotations. Also fixes a real gap the rule caught: vtex/utils/sitemap.ts's fetchImpl defaulted to raw fetch, same unbounded-hang risk as the other call sites. Co-Authored-By: Claude Sonnet 5 --- biome.json | 30 +++++++++++++++++++++++++++++- commerce/utils/fetchTimeout.ts | 8 ++++++++ shopify/client.ts | 5 +++-- shopify/utils/graphql.ts | 4 ++-- shopify/utils/instrumentedFetch.ts | 3 ++- vtex/client.ts | 6 +++--- vtex/utils/instrumentedFetch.ts | 3 ++- vtex/utils/sitemap.ts | 5 +++-- 8 files changed, 52 insertions(+), 12 deletions(-) diff --git a/biome.json b/biome.json index baa3647..df3584e 100644 --- a/biome.json +++ b/biome.json @@ -45,5 +45,33 @@ "website/**", "vitest.config.ts" ] - } + }, + "overrides": [ + { + "includes": [ + "vtex/**", + "magento/**", + "resend/**", + "shopify/**", + "website/**", + "!vtex/hooks/**", + "!**/*.test.ts", + "!**/__tests__/**" + ], + "linter": { + "rules": { + "style": { + "noRestrictedGlobals": { + "level": "error", + "options": { + "deniedGlobals": { + "fetch": "Bare fetch() skips the request timeout. Use this package's timeout-wrapped client (e.g. fetchSafe/vtexFetch/magentoFetch/withFetchTimeout from commerce/utils/fetchTimeout) instead." + } + } + } + } + } + } + } + ] } diff --git a/commerce/utils/fetchTimeout.ts b/commerce/utils/fetchTimeout.ts index b976f79..5ff7f4e 100644 --- a/commerce/utils/fetchTimeout.ts +++ b/commerce/utils/fetchTimeout.ts @@ -20,6 +20,14 @@ /** Default per-request timeout for outbound fetch calls. */ export const DEFAULT_FETCH_TIMEOUT_MS = 10_000; +/** + * Named alias for `typeof fetch`. Prefer this over repeating `typeof fetch` + * in signatures — this repo bans the bare `fetch` global (see `biome.json`'s + * `noRestrictedGlobals` override) and that ban also flags `typeof fetch`, + * since it's the same identifier reference; `FetchFn` sidesteps that. + */ +export type FetchFn = typeof fetch; + /** * Combine a caller-supplied `AbortSignal` (if any) with a timeout signal, so * neither cancellation source is lost. Pass `timeoutMs <= 0` or non-finite diff --git a/shopify/client.ts b/shopify/client.ts index 834bb62..48a39bf 100644 --- a/shopify/client.ts +++ b/shopify/client.ts @@ -1,3 +1,4 @@ +import type { FetchFn } from "../commerce/utils/fetchTimeout"; import { createGraphqlClient, type GraphQLClient } from "./utils/graphql"; export interface ShopifyConfig { @@ -8,7 +9,7 @@ export interface ShopifyConfig { let _client: GraphQLClient | null = null; let _config: ShopifyConfig | null = null; -let _fetch: typeof fetch | undefined; +let _fetch: FetchFn | undefined; /** * Override the fetch function used by the Shopify GraphQL client. @@ -21,7 +22,7 @@ let _fetch: typeof fetch | undefined; * setShopifyFetch(createInstrumentedFetch("shopify")); * ``` */ -export function setShopifyFetch(fetchFn: typeof fetch) { +export function setShopifyFetch(fetchFn: FetchFn) { _fetch = fetchFn; if (_config) configureShopify(_config); } diff --git a/shopify/utils/graphql.ts b/shopify/utils/graphql.ts index 076cb04..1ceee2b 100644 --- a/shopify/utils/graphql.ts +++ b/shopify/utils/graphql.ts @@ -1,5 +1,5 @@ import type { InstrumentedFetchInit } from "@decocms/start/sdk/instrumentedFetch"; -import { withFetchTimeout } from "../../commerce/utils/fetchTimeout"; +import { type FetchFn, withFetchTimeout } from "../../commerce/utils/fetchTimeout"; import { extractGraphqlOperationName } from "./graphqlOperationName"; export function gql(strings: TemplateStringsArray, ...values: unknown[]): string { @@ -23,7 +23,7 @@ export interface GraphQLClient { export function createGraphqlClient( endpoint: string, headers: Record, - fetchFn?: typeof fetch, + fetchFn?: FetchFn, ): GraphQLClient { const _fetch = fetchFn ?? withFetchTimeout(); return { diff --git a/shopify/utils/instrumentedFetch.ts b/shopify/utils/instrumentedFetch.ts index e027ffd..302173f 100644 --- a/shopify/utils/instrumentedFetch.ts +++ b/shopify/utils/instrumentedFetch.ts @@ -23,6 +23,7 @@ * extractor returns `undefined`. */ +import type { FetchFn } from "../../commerce/utils/fetchTimeout"; import { createInstrumentedFetch, type InstrumentedFetch, @@ -31,7 +32,7 @@ import { recordCommerceMetric } from "@decocms/start/sdk/observability"; import { shopifyOperationRouter } from "./operationRouter"; export interface CreateShopifyFetchOptions { - baseFetch?: typeof fetch; + baseFetch?: FetchFn; disableHistogram?: boolean; } diff --git a/vtex/client.ts b/vtex/client.ts index 307e045..ca87f01 100644 --- a/vtex/client.ts +++ b/vtex/client.ts @@ -8,7 +8,7 @@ import type { InstrumentedFetchInit, } from "@decocms/start/sdk/instrumentedFetch"; import { RequestContext } from "@decocms/start/sdk/requestContext"; -import { withFetchTimeout } from "../commerce/utils/fetchTimeout"; +import { type FetchFn, withFetchTimeout } from "../commerce/utils/fetchTimeout"; import { sanitizeOutboundCookieHeader, warnDroppedCookies } from "./utils/cookieSanitizer"; import { type FetchCacheOptions, fetchWithCache } from "./utils/fetchCache"; import { ANONYMOUS_COOKIE, SESSION_COOKIE } from "./utils/intelligentSearch"; @@ -148,7 +148,7 @@ export interface VtexConfig { } let _config: VtexConfig | null = null; -let _fetch: typeof fetch | InstrumentedFetch = withFetchTimeout(); +let _fetch: FetchFn | InstrumentedFetch = withFetchTimeout(); export function configureVtex(config: VtexConfig) { _config = config; @@ -170,7 +170,7 @@ export function configureVtex(config: VtexConfig) { * uninstrumented (useful for tests + sites that haven't onboarded * the observability stack yet). */ -export function setVtexFetch(fetchFn: typeof fetch | InstrumentedFetch) { +export function setVtexFetch(fetchFn: FetchFn | InstrumentedFetch) { _fetch = fetchFn; } diff --git a/vtex/utils/instrumentedFetch.ts b/vtex/utils/instrumentedFetch.ts index 386548d..b1ca3e0 100644 --- a/vtex/utils/instrumentedFetch.ts +++ b/vtex/utils/instrumentedFetch.ts @@ -30,6 +30,7 @@ * behavior. */ +import type { FetchFn } from "../../commerce/utils/fetchTimeout"; import { createInstrumentedFetch, type InstrumentedFetch, @@ -44,7 +45,7 @@ export interface CreateVtexFetchOptions { * or routes through a proxy) to preserve its behavior while adding * the VTEX instrumentation layer on top. */ - baseFetch?: typeof fetch; + baseFetch?: FetchFn; /** * Disable the `http.client.request.duration` histogram emission for * VTEX calls. The framework's span and structured logs still emit. diff --git a/vtex/utils/sitemap.ts b/vtex/utils/sitemap.ts index e4035c0..b8a4c1a 100644 --- a/vtex/utils/sitemap.ts +++ b/vtex/utils/sitemap.ts @@ -11,6 +11,7 @@ * needs to expose VTEX's existing crawl tree to the public hostname. */ +import { type FetchFn, withFetchTimeout } from "../../commerce/utils/fetchTimeout"; import { getVtexConfig, vtexFetchResponse, vtexHost } from "../client"; export interface SitemapEntry { @@ -185,7 +186,7 @@ export interface VtexSitemapProxyConfig { * Optional fetch override — primarily for tests. Defaults to the * platform `fetch`. */ - fetchImpl?: typeof fetch; + fetchImpl?: FetchFn; } const DEFAULT_SITEMAP_CACHE_CONTROL = "public, s-maxage=3600, stale-while-revalidate=86400"; @@ -238,7 +239,7 @@ export function createVtexSitemapProxy( const environment = config.environment ?? "vtexcommercestable"; const cacheControl = config.cacheControl ?? DEFAULT_SITEMAP_CACHE_CONTROL; const extraSitemaps = config.extraSitemaps ?? []; - const fetchImpl = config.fetchImpl ?? fetch; + const fetchImpl = config.fetchImpl ?? withFetchTimeout(); return async (_request: Request, url: URL): Promise => { if (!isVtexSitemapPath(url.pathname)) return null;