Skip to content

feat: VTEX upstream resilience (abort, breaker, stale-if-error, anti-poisoning, error boundary)#384

Open
JonasJesus42 wants to merge 6 commits into
mainfrom
feat/vtex-upstream-resilience
Open

feat: VTEX upstream resilience (abort, breaker, stale-if-error, anti-poisoning, error boundary)#384
JonasJesus42 wants to merge 6 commits into
mainfrom
feat/vtex-upstream-resilience

Conversation

@JonasJesus42

@JonasJesus42 JonasJesus42 commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Contexto

Lentidão do upstream VTEX vinha derrubando storefronts TanStack (Bagaggio 2026-07-18; antes dental-partner, vicbeaute, meiasola) com a mesma assinatura, que cascateia em três falhas:

  • 499 — o cliente aborta porque o SSR fica pendurado esperando a VTEX (o timeout antigo do fetchCache era um Promise.race que só liberava o slot de dedup, não o socket);
  • 500 — throw não capturado na resolução da página, e o caminho de checkout/cart POST não tinha timeout/retry nenhum;
  • cache poisoning — um loader que falha degrada para um 200 com dados vazios que o edge cacheia como saudável, então o stale-if-error nunca dispara e um blip de segundos vira horas de páginas vazias.

Este PR resolve no framework para que todos os sites herdem. Princípio: uma chamada VTEX lenta/com erro nunca deve virar 500 nem um 200-quebrado cacheável — sempre degradar para (1) último dado bom, (2) fail-fast do breaker, (3) página branded, nessa ordem.

O que muda

1. Resiliência na camada de fetch VTEX (apps-vtex) — 997ce97, d97e659

  • Novo utils/resilience.ts, composto sob a instrumentação em createVtexFetch({ baseFetch }) → cobre 100% da egress (GET cacheado, IS e checkout/cart POSTs), sem tocar em call-sites.
  • AbortController com timeout real (~8s/tentativa, cap 12s) → libera o socket, mata os 499.
  • Retry só idempotente (GET/HEAD; POST de checkout nunca retentado) com backoff+jitter e retry budget por host (token bucket) → sem retry-storm.
  • Circuit breaker por host → fail-fast + load-shedding numa queda ampla; half-open com probes.
  • Une o RequestContext.signal (disconnect do cliente / deadline) ao fetch → cancelamento propaga.
  • ON por default; kill-switch VTEX_RESILIENCE_DISABLED=true.

2. Stale-if-error na camada de dados (apps-vtex/fetchCache) — 997ce97

  • VtexCircuitOpenError/timeout são não-retryáveis (fast-fail).
  • Serve-stale limitado a 24h (antes era ilimitado — serviria dado de semanas atrás). Chave quente sobrevive a uma queda total com zero carga no upstream; depois de 24h o erro aflora.

3. Anti-cache-poisoning: sinal X-Deco-Degraded (blocks + tanstack) — 388d147

  • blocks/sectionLoaders: registra cada seção crítica que degradou num coletor request-scoped (getDegradedSections). Crítico por default; registerNonCriticalSections faz opt-out de decorativas.
  • tanstack/cmsRoute: emite X-Deco-Degraded: true quando há degradação crítica.
  • tanstack/workerEntry: trata 200 + X-Deco-Degraded como 5xx — nunca armazena, e prefere entrada quente (STALE-ERROR); cache frio serve no-store.

4. errorComponent default nas rotas CMS (tanstack/cmsRoute) — 327b1e4

  • cmsRouteConfig/cmsHomeRouteConfig ganham um CmsPageErrorFallback branded (com "tentar de novo") em vez de 500 cru. É o último recurso de cache frio — o edge serve STALE-ERROR antes disso. Sobreponível via cmsRouteConfig({ errorComponent }).

Testes

Todos verdes localmente:

  • apps-vtex: 230 (novos: 9 resilience + 2 fetchCache SIE/fast-fail)
  • blocks: 547 (novos: 4 de degradação de seção)
  • tanstack: 109 (novos: 2 do branch degraded no worker)
  • biome check e tsc --noEmit limpos nos arquivos alterados.

Notas de rollout / follow-up

  • Mudança de comportamento default (resiliência ON, serve-stale agora limitado a 24h). Kill-switch por env para emergência.
  • Falta smoke test de integração (examples/tanstack-smoke) confirmando que X-Deco-Degraded (set via setResponseHeader no loader SSR) chega ao workerEntry no caminho do documento — segue o mesmo mecanismo do X-Deco-Cacheable já existente, mas não é coberto por unit test.
  • Deadline global de SSR + timeout por-loader no caminho normal (sectionLoaders/resolve commerce loader) ficaram de fora — o abort real por-chamada + breaker já limitam bastante o hang; podem entrar como hardening depois.
  • fetch.ts (fetchSafe/fetchAPI, usado por sitemap/loaders ad-hoc) não passa pelo _fetch e segue sem resiliência — fora de escopo.

🤖 Generated with Claude Code


Summary by cubic

Adds upstream resilience for VTEX calls and anti-cache-poisoning for degraded pages, so storefronts keep serving last-good data during VTEX slowness instead of 499/500 or empty cached pages. Also adds a default CMS error boundary to avoid raw 500s.

  • New Features

    • apps-vtex: Resilient VTEX fetch under createVtexFetch (real per-attempt abort/timeout, idempotent-only retry with backoff + per-host token-bucket, per-host circuit breaker with half-open probes, propagates RequestContext.signal, caller/ambient aborts do not trip the breaker; kill-switch VTEX_RESILIENCE_DISABLED=true). All resilience/cache knobs are centralized in utils/constants.ts; histograms use statusClassFor.
    • apps-vtex/fetchCache: Single attempt (retries live in the resilience layer); stale-if-error serves last-good for up to 24h; circuit-open and timeout errors are non-retryable (fast-fail); inflight backstop timeout raised to 15s to avoid killing near-complete responses.
    • blocks + tanstack: Track critical section degradations (commerce loaders flagged; layout sections excluded) and emit X-Deco-Degraded: true via route headers; deferred sections are not marked cacheable when degraded; the worker treats a degraded 200 like 5xx (serve warm STALE-ERROR, else no-store).
    • tanstack/cmsRoute: Default CmsPageErrorFallback (retry button), overridable via cmsRouteConfig({ errorComponent }); homepage wired into the degraded signal.
  • Migration

    • Resilience is ON by default; set VTEX_RESILIENCE_DISABLED=true to disable in emergencies.
    • Stale-if-error is now capped at 24h (was effectively unbounded).
    • Mark decorative sections as non-critical via registerNonCriticalSections([...]) if their empty state is safe to cache.

Written for commit 23810b2. Summary will update on new commits.

Review in cubic

JonasJesus42 and others added 4 commits July 20, 2026 11:56
…e-if-error

Wrap all VTEX egress (createVtexFetch baseFetch) with a real AbortController
timeout (frees the socket → kills client-abort 499s on SSR hangs), an
idempotent-only retry with a per-host token-bucket budget (checkout POSTs are
never retried; no retry storm), and a per-host circuit breaker (fail-fast +
load shedding under a broad outage).

fetchCache: treat circuit-open/timeout as non-retryable (fast-fail), and bound
stale-if-error serving to a 24h window (was effectively unbounded) so warm keys
survive a full outage with zero upstream pressure, then surface the error.

ON by default; VTEX_RESILIENCE_DISABLED=true is a runtime kill-switch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cmsRouteConfig / cmsHomeRouteConfig had no errorComponent and
loadCmsPageInternal has no try/catch, so an uncaught throw in page resolution
(resolveDecoPage / buildPageSeo / resolveSiteGlobals) during an outage became a
raw 500 white screen. Ship a default branded CmsPageErrorFallback (retry
button) as the cold-cache last resort — the edge still serves STALE-ERROR from
a warm entry first. Sites override via cmsRouteConfig({ errorComponent }).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a critical section loader throws, the section renders with raw/empty props
(e.g. an empty shelf during a VTEX outage) and the page is emitted as a healthy
200. The edge then caches that broken page for the whole retention window and
stale-if-error never fires — a seconds-long blip becomes hours of empty pages.

- blocks/sectionLoaders: record each critical section degradation on a
  request-scoped collector (getDegradedSections). Sections are critical by
  default; registerNonCriticalSections opts decorative ones out.
- tanstack/cmsRoute: emit X-Deco-Degraded: true when any critical section
  degraded, and expose degraded on loader data.
- tanstack/workerEntry: treat a 200 + X-Deco-Degraded like a 5xx — never store
  it, and prefer a warm stale entry (STALE-ERROR); cold cache serves it no-store.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Union RequestContext.signal (client disconnect / request-level SSR deadline)
with the caller signal and the per-attempt timeout, so a disconnected client or
an aborted request cancels the in-flight VTEX call and frees the socket instead
of letting the SSR render hang — the remaining 499 vector after the per-attempt
abort landed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@JonasJesus42
JonasJesus42 requested a review from a team July 20, 2026 15:09
JonasJesus42 and others added 2 commits July 21, 2026 07:23
- resilience: a caller/ambient abort (client disconnect via RequestContext.signal)
  is no longer counted as a breaker failure or retried — only our own timeout
  and real network/5xx failures trip the breaker. Prevents client behavior from
  opening the circuit against a healthy VTEX.
- fetchCache: single attempt — the resilience layer owns retries + breaker.
  Eliminates the double-retry (up to 9 upstream calls) and the ~3x breaker
  over-counting on 5xx. Raise the inflight backstop timeout to 15s (above the
  resilience 12s total) so it never kills a slow-but-recoverable response,
  discards the body, or defeats dedup.
- resolve: instrument the commerce-loader catch (VTEX PDP/PLP data — the actual
  incident path) with markSectionDegraded, so an empty product page is not
  cached as healthy.
- cmsRoute: wire the homepage (loadCmsHomePage) into the degraded signal; emit
  X-Deco-Degraded via the route headers() callback (reliable document channel,
  not just setResponseHeader); gate deferred sections so a degraded shelf is
  not marked X-Deco-Cacheable.
- sectionLoaders: stop marking LAYOUT (Header/Footer) failures as degraded — a
  flaky layout loader would flip the whole site to uncacheable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ic values

- New utils/constants.ts is the single place every timeout/limit/threshold for
  the resilience + fetch-cache layer lives, each documented in one spot instead
  of scattered across fetchCache.ts and resilience.ts (DEFAULT_RESILIENCE_CONFIG,
  TTL_BY_STATUS, STALE_IF_ERROR_MS, FETCH_TIMEOUT_MS, DEFAULT_MAX_ENTRIES).
- fetchCache.ts: TTL_BY_STATUS's opaque "2xx"/"404"/"5xx" string-keyed record
  becomes FETCH_CACHE_FRESH_TTL_MS.{success,notFound,serverError} — reads at the
  call site without cross-referencing the map. ttlForStatus -> freshTtlForStatus
  (was ambiguous against the new stale-if-error TTL).
- instrumentedFetch.ts: replace the inline status/100-floor expression (opaque
  without re-deriving what it computes) with the already-exported
  statusClassFor helper from @decocms/blocks/sdk/observability — also removes
  a second, drifting implementation of the same status-class mapping.
- resilience.ts re-exports ResilienceConfig/DEFAULT_RESILIENCE_CONFIG from
  constants.ts so the one external import site is unaffected.

No behavior change — same defaults, same tests (232/548/109 still pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant