Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
}
}
}
}
]
}
64 changes: 64 additions & 0 deletions commerce/utils/fetchTimeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* 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;

/**
* 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
* 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),
});
}
6 changes: 5 additions & 1 deletion magento/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
* Magento has consistent muscle memory.
*/

import { withFetchTimeout } from "../commerce/utils/fetchTimeout";

const timeoutFetch = withFetchTimeout();

// ---------------------------------------------------------------------------
// Config shapes
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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) });
}
28 changes: 16 additions & 12 deletions resend/__tests__/send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <test@example.com>",
to: "user@example.com",
subject: "Hello",
html: "<p>World</p>",
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 <test@example.com>",
to: "user@example.com",
subject: "Hello",
html: "<p>World</p>",
}),
signal: expect.any(AbortSignal),
}),
});
);
});

it("falls back to defaults when fields are omitted", async () => {
Expand Down
5 changes: 4 additions & 1 deletion resend/actions/send.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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}`,
Expand Down
5 changes: 3 additions & 2 deletions shopify/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { FetchFn } from "../commerce/utils/fetchTimeout";
import { createGraphqlClient, type GraphQLClient } from "./utils/graphql";

export interface ShopifyConfig {
Expand All @@ -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.
Expand All @@ -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);
}
Expand Down
5 changes: 3 additions & 2 deletions shopify/utils/graphql.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { InstrumentedFetchInit } from "@decocms/start/sdk/instrumentedFetch";
import { type FetchFn, withFetchTimeout } from "../../commerce/utils/fetchTimeout";
import { extractGraphqlOperationName } from "./graphqlOperationName";

export function gql(strings: TemplateStringsArray, ...values: unknown[]): string {
Expand All @@ -22,9 +23,9 @@ export interface GraphQLClient {
export function createGraphqlClient(
endpoint: string,
headers: Record<string, string>,
fetchFn?: typeof fetch,
fetchFn?: FetchFn,
): GraphQLClient {
const _fetch = fetchFn ?? globalThis.fetch;
const _fetch = fetchFn ?? withFetchTimeout();
return {
async query<T>(
queryOrDef: string | QueryDefinition,
Expand Down
3 changes: 2 additions & 1 deletion shopify/utils/instrumentedFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
* extractor returns `undefined`.
*/

import type { FetchFn } from "../../commerce/utils/fetchTimeout";
import {
createInstrumentedFetch,
type InstrumentedFetch,
Expand All @@ -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;
}

Expand Down
5 changes: 3 additions & 2 deletions vtex/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
InstrumentedFetchInit,
} from "@decocms/start/sdk/instrumentedFetch";
import { RequestContext } from "@decocms/start/sdk/requestContext";
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";
Expand Down Expand Up @@ -147,7 +148,7 @@ export interface VtexConfig {
}

let _config: VtexConfig | null = null;
let _fetch: typeof fetch | InstrumentedFetch = globalThis.fetch;
let _fetch: FetchFn | InstrumentedFetch = withFetchTimeout();

export function configureVtex(config: VtexConfig) {
_config = config;
Expand All @@ -169,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;
}

Expand Down
6 changes: 5 additions & 1 deletion vtex/utils/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
* the platform.
*/

import { withFetchTimeout } from "../../commerce/utils/fetchTimeout";

const timeoutFetch = withFetchTimeout();

type CachingMode = "stale-while-revalidate";

type DecoInit = {
Expand Down Expand Up @@ -88,7 +92,7 @@ export async function fetchSafe(
init?: DecoRequestInit,
): Promise<Response> {
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);
}
Expand Down
3 changes: 2 additions & 1 deletion vtex/utils/instrumentedFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
* behavior.
*/

import type { FetchFn } from "../../commerce/utils/fetchTimeout";
import {
createInstrumentedFetch,
type InstrumentedFetch,
Expand All @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions vtex/utils/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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<Response | null> => {
if (!isVtexSitemapPath(url.pathname)) return null;
Expand Down
7 changes: 5 additions & 2 deletions website/loaders/fonts/googleFonts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { withFetchTimeout } from "../../../commerce/utils/fetchTimeout";
import type { Font } from "../../types";

const timeoutFetch = withFetchTimeout();

interface Props {
fonts: GoogleFont[];
}
Expand Down Expand Up @@ -94,13 +97,13 @@ const loader = async (props: Props): Promise<Font> => {
};

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);
Expand Down
Loading