diff --git a/.claude/skills/nextjs-netlify-caching/SKILL.md b/.claude/skills/nextjs-netlify-caching/SKILL.md index 126da4707..e53340aa8 100644 --- a/.claude/skills/nextjs-netlify-caching/SKILL.md +++ b/.claude/skills/nextjs-netlify-caching/SKILL.md @@ -197,6 +197,23 @@ The function runs with `AWS_LAMBDA_FUNCTION_MEMORY_SIZE=1024`, a V8 heap limit o Do not treat the platform ceiling as a backstop either. Netlify documents 10 s by default and 26 s maximum on paid plans, yet invocations of 26.4 s to 31.9 s were logged on this Pro account. The stall itself is tracked as issue #1124; #1120 is closed by #1123, which established that it is not a database problem. +### The 2026-08-22 memory A/B: assessed and reverted — and what it disproves + +The "CPU-starved cold-boot" reading above became testable when Netlify shipped per-function `memory`/`vcpu` config (Credit-based Pro/Ent; `memory` and `vcpu` **scale together**, so setting either tests the same lever). Applied correctly at 2048 MB to the v2 handler and measured under the identical 12-way concurrent unique-key burst protocol: + +| Deploy | Handler memory | Result | +|---|---|---| +| control (1024 MB) | 1024 | 11/12 slow, TTFB 27.8–31.0 s | +| treatment | 2048 | 11/12 slow, TTFB 35.9–37.6 s + one platform 500 | + +No improvement, possibly worse. Reverted in 08b10ce4. Two conclusions: the CPU-share hypothesis for the stall is **weakened**, not confirmed — doubling per-instance CPU should have shrunk a CPU-bound stall and moved nothing; and any artifact claiming memory "resolved" the stall descends from a misattributed burst that ran during a hyperactive window (three deploys + two agent sessions within nine minutes) where residual warm capacity produced the fast numbers. + +**Method traps this cost a day to learn.** Runtime API v2 generates ONE function named `___netlify-server-handler`; overrides targeting v1 names (`___netlify-handler`, `___netlify-odb-handler`) are silently ignored — verify with `netlify api searchSiteFunctions --data '{"site_id":"…"}'` (record field `m`). Netlify deploy IDs do not visibly map to commits: `netlify api listSiteDeploys` → `commit_ref` does, and every cross-deploy claim must use it. Cross-preview A/Bs confound ISR cache freshness with instance-pool age; only same-deploy comparisons count. + +**Where this leaves the levers.** Warming workflows (#1148 keep-warm/warm-deploy) protect only the lone-click case — one ping keeps one instance warm and cannot cover bursts. App-side init work is bounded by measurement (solo new instance = full boot + render in ~1.9 s), so shaving SDK init buys fractions of that budget, nothing more. If the tail remains unacceptable after code hygiene, the remaining lever is architectural — always-on compute for SSR — not more warming machinery. A support ticket with the evidence pack lives at `docs/perf/netlify-stall-ticket-draft.md`. + +The warm-then-burst close-out (2026-08-23, preview-1148): idle→6-burst stalled 6/6 at 30.6–32.0 s; ~150 s of sustained sequential traffic kept essentially ONE instance warm (every subsequent unique-key RSC fetch served from the ISR/durable cache at ~0.24 s); an immediate 12-burst still stalled 4/12 at 27.8–30.3 s plus a platform 504. Concurrency width alone forces fresh instances into the stall seconds after heavy activity — no ping cadence can prevent it. The same day, a build carrying lazy-initialized Razorpay/Stripe clients (#1221) reproduced the stall at full strength (12/12 slow, 29.5–31.5 s after ≥30 min idle) while its sequential profile was textbook — second independent confirmation, after the CPU-doubling null result, that the stall does not scale with application init work. Do not re-propose bundle-shaving as a stall fix. + ### Fail-open and a cacheable response are safe alone and dangerous together A fail-open path on an ISR route converts a transient database blip into a cached artefact. `fallbackOnTransientDbError` rethrew during `next build` but degraded at request time, and on `/explore/experts/[consultantId]` that produced HTTP 200 responses carrying the degraded shell at 66 KB against a healthy 104–118 KB. Ten of forty concurrent cold renders came back that way, each with `Cache-Status: "Netlify Durable"; fwd=uri-miss; stored`, and re-fetching them five minutes later returned the same broken page in 0.30–0.64 s with `"Netlify Durable"; hit` and `age: 318–350`. The broken page becomes the *fast* one, which is why nobody notices. diff --git a/__tests__/payments/confirmation-single-writer.test.ts b/__tests__/payments/confirmation-single-writer.test.ts index e06532618..2105c1c25 100644 --- a/__tests__/payments/confirmation-single-writer.test.ts +++ b/__tests__/payments/confirmation-single-writer.test.ts @@ -48,6 +48,9 @@ jest.mock("../../lib/payments/core/razorpay", () => ({ razorpayClient: { payments: { fetch: (...a: unknown[]) => paymentsFetch(...a) }, }, + getRazorpayClient: () => ({ + payments: { fetch: (...a: unknown[]) => paymentsFetch(...a) }, + }), })); const getSession = jest.fn(); diff --git a/__tests__/payments/dispute-earnings-hardening.test.ts b/__tests__/payments/dispute-earnings-hardening.test.ts index e8eb66c70..f89e16b06 100644 --- a/__tests__/payments/dispute-earnings-hardening.test.ts +++ b/__tests__/payments/dispute-earnings-hardening.test.ts @@ -33,8 +33,12 @@ jest.mock("../../lib/payments/core/razorpay", () => ({ razorpayClient: { payments: { fetch: (...a: unknown[]) => razorpayPaymentsFetch(...a) }, }, + // #1221 made utils.ts consume the lazy getter; serve both shapes. + getRazorpayClient: () => ({ + payments: { fetch: (...a: unknown[]) => razorpayPaymentsFetch(...a) }, + }), })); -jest.mock("../../lib/payments/core/stripe", () => ({ stripeClient: null })); +jest.mock("../../lib/payments/core/stripe", () => ({ stripeClient: null, getStripeClient: () => null })); jest.mock("../../lib/novu", () => ({ notifyRefundProcessed: jest.fn(), notifyDisputeCreated: jest.fn(), diff --git a/__tests__/payments/dispute-refund-correctness.test.ts b/__tests__/payments/dispute-refund-correctness.test.ts index 45d3d3711..80fafec14 100644 --- a/__tests__/payments/dispute-refund-correctness.test.ts +++ b/__tests__/payments/dispute-refund-correctness.test.ts @@ -30,8 +30,9 @@ jest.mock("../../lib/enterprise/system-events", () => ({ jest.mock("../../lib/payments/core/razorpay", () => ({ __esModule: true, razorpayClient: { payments: { fetch: (...a: unknown[]) => razorpayPaymentsFetch(...a) } }, + getRazorpayClient: () => ({ payments: { fetch: (...a: unknown[]) => razorpayPaymentsFetch(...a) } }), })); -jest.mock("../../lib/payments/core/stripe", () => ({ stripeClient: null })); +jest.mock("../../lib/payments/core/stripe", () => ({ stripeClient: null, getStripeClient: () => null })); // Minimal stubs for the rest of utils.ts's import graph so module load works. jest.mock("../../lib/novu", () => ({ diff --git a/__tests__/payments/razorpay-test-key-guard.test.ts b/__tests__/payments/razorpay-test-key-guard.test.ts index 49e0b627b..b5a84fc84 100644 --- a/__tests__/payments/razorpay-test-key-guard.test.ts +++ b/__tests__/payments/razorpay-test-key-guard.test.ts @@ -18,6 +18,9 @@ * * Each test re-requires the modules under jest.resetModules() because both * clients initialize at module load / factory-first-call from process.env. + * (#1221 made the CORE client construct lazily — the PM-10 guard still fires + * at module load as a cheap env check, while SDK construction happens on the + * first getRazorpayClient() call. The assertions below follow that split.) * Error-code asserts are duck-typed (not instanceof) on purpose: resetModules * means the PaymentError class inside the fresh module registry is a * different constructor than any top-level import here. @@ -92,9 +95,7 @@ describe("core checkout/refund client (lib/payments/core/razorpay.ts)", () => { process.env.RAZORPAY_KEY_ID = "rzp_test_wrongposture"; process.env.RAZORPAY_SECRET = "some_secret"; - const thrown = captureThrow( - () => requireCoreModule().razorpayClient, - ); + const thrown = captureThrow(() => requireCoreModule()); expect(thrown.message).toMatch(/RAZORPAY_KEY_ID/); expect(thrown.message).toMatch(/rzp_test_wrongposture/); @@ -109,7 +110,7 @@ describe("core checkout/refund client (lib/payments/core/razorpay.ts)", () => { const mod = requireCoreModule(); - expect(mod.razorpayClient).not.toBeNull(); + expect(mod.getRazorpayClient()).not.toBeNull(); }); it("next build phase + prod posture + rzp_test_ key → initializes fine (builds move no money)", () => { @@ -127,7 +128,7 @@ describe("core checkout/refund client (lib/payments/core/razorpay.ts)", () => { const mod = requireCoreModule(); - expect(mod.razorpayClient).not.toBeNull(); + expect(mod.getRazorpayClient()).not.toBeNull(); }); }); diff --git a/app/api/checkout/verify-signature/route.ts b/app/api/checkout/verify-signature/route.ts index e49a16377..6ff2d499c 100644 --- a/app/api/checkout/verify-signature/route.ts +++ b/app/api/checkout/verify-signature/route.ts @@ -33,7 +33,7 @@ import { NextRequest, NextResponse, after } from "next/server"; import crypto from "crypto"; import prisma from "@/lib/prisma"; import { getSession } from "@/lib/auth-server"; -import { razorpayClient } from "@/lib/payments/core/razorpay"; +import { getRazorpayClient } from "@/lib/payments/core/razorpay"; import { routeCapturedPayment } from "@/app/api/webhooks/razorpay-dispatch"; import { z } from "zod"; @@ -48,6 +48,7 @@ const verifySignatureSchema = z.object({ export async function POST(req: NextRequest) { try { + const razorpayClient = getRazorpayClient(); const session = await getSession(); if (!session?.user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/app/api/checkout/verify/route.ts b/app/api/checkout/verify/route.ts index 58b1e4ba5..aaafc100c 100644 --- a/app/api/checkout/verify/route.ts +++ b/app/api/checkout/verify/route.ts @@ -2,11 +2,12 @@ import * as Sentry from "@sentry/nextjs"; import { NextRequest, NextResponse } from "next/server"; import prisma from "@/lib/prisma"; import { getSession } from "@/lib/auth-server"; -import { razorpayClient } from "@/lib/payments/core/razorpay"; +import { getRazorpayClient } from "@/lib/payments/core/razorpay"; import { routeCapturedPayment } from "@/app/api/webhooks/razorpay-dispatch"; export async function GET(req: NextRequest) { try { + const razorpayClient = getRazorpayClient(); // Check authentication const session = await getSession(); if (!session?.user) { diff --git a/app/api/overage/[overageEventId]/order/route.ts b/app/api/overage/[overageEventId]/order/route.ts index 71f07a382..44496b661 100644 --- a/app/api/overage/[overageEventId]/order/route.ts +++ b/app/api/overage/[overageEventId]/order/route.ts @@ -5,7 +5,7 @@ import { headers } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; import { createRazorpayOrder, - razorpayClient, + getRazorpayClient, } from "@/lib/payments/core/razorpay"; import { PaymentStatus } from "@prisma/client"; import { transitionOverage } from "@/lib/payments/billing/overage-transitions"; @@ -143,6 +143,7 @@ export async function POST( // the stored order has already been paid we fall through and mint a new one, // because the webhook for the paid order owns that outcome. const existingOrderId = event.payment.paymentIntent; + const razorpayClient = getRazorpayClient(); if (razorpayClient && existingOrderId?.startsWith("order_")) { try { const existingOrder = await razorpayClient.orders.fetch(existingOrderId); diff --git a/app/api/webhooks/razorpay-dispatch.ts b/app/api/webhooks/razorpay-dispatch.ts index bdc945ee6..94649adf8 100644 --- a/app/api/webhooks/razorpay-dispatch.ts +++ b/app/api/webhooks/razorpay-dispatch.ts @@ -32,7 +32,7 @@ import { razorpayOrderPaidEventSchema, type RazorpayWebhookEnvelope, } from "@/schemas/webhooks/razorpay"; -import { razorpayClient } from "@/lib/payments/core/razorpay"; +import { getRazorpayClient } from "@/lib/payments/core/razorpay"; import { z } from "zod"; // Strict inner-entity schemas used to narrow optional envelope fields at the @@ -209,6 +209,9 @@ export async function processRazorpayWebhookEvent( ); let paymentIntentId = refundEvent.payment_id; + // Only the refund family resolves payment_id → order_id via the SDK; + // other event branches must not construct a client. + const razorpayClient = getRazorpayClient(); if (razorpayClient) { try { const rzpPayment = await razorpayClient.payments.fetch( @@ -259,6 +262,7 @@ export async function processRazorpayWebhookEvent( ); let failedPaymentIntentId = failedRefundEvent.payment_id; + const razorpayClient = getRazorpayClient(); if (razorpayClient) { try { const rzpPayment = await razorpayClient.payments.fetch( diff --git a/app/api/webhooks/utils.ts b/app/api/webhooks/utils.ts index 9e15d1d64..42bcee641 100644 --- a/app/api/webhooks/utils.ts +++ b/app/api/webhooks/utils.ts @@ -8,8 +8,8 @@ import { } from "@/lib/payments/dispute-status"; import { Prisma, PaymentGateway } from "@prisma/client"; import crypto from "crypto"; -import { stripeClient } from "@/lib/payments/core/stripe"; -import { razorpayClient } from "@/lib/payments/core/razorpay"; +import { getStripeClient } from "@/lib/payments/core/stripe"; +import { getRazorpayClient } from "@/lib/payments/core/razorpay"; import { handlePayoutWebhook } from "@/lib/payments/payouts"; import { notifyRefundProcessed, @@ -544,6 +544,9 @@ export async function verifyWebhookSignature( try { if (gateway === "stripe") { + // Only Stripe verification touches the SDK client; Razorpay verifies + // via local HMAC below. + const stripeClient = getStripeClient(); if (!stripeClient) { console.error( "Stripe client not initialized - cannot verify webhook signature", @@ -1159,6 +1162,9 @@ export async function handleDisputeCreated( isChargeRefundable: boolean, gateway: "STRIPE" | "RAZORPAY", ) { + // Only resolve the client the dispute's gateway will use. + const stripeClient = gateway === "STRIPE" ? getStripeClient() : null; + const razorpayClient = gateway === "RAZORPAY" ? getRazorpayClient() : null; // Resolve `chargeId` to OUR paymentIntent BEFORE opening the transaction. // This lookup is an external HTTP call to Stripe or Razorpay; leaving it // inside the tx held a database transaction open across a network round trip, diff --git a/docs/perf/netlify-stall-ticket-draft.md b/docs/perf/netlify-stall-ticket-draft.md new file mode 100644 index 000000000..107445663 --- /dev/null +++ b/docs/perf/netlify-stall-ticket-draft.md @@ -0,0 +1,55 @@ +# Netlify Pro support ticket — DRAFT (for kaustav to file) + +> Subject: Next.js server handler (`___netlify-server-handler`): brand-new instances block their event loop ~24s on first invocation when created concurrently — is this expected scale-out behavior? +> +> Site: familiarise.netlify.app (site id `1a1ad7d0-fda0-4efe-9d58-aa0ce0fd6d5c`) +> Plan: Pro (Credit-based) · Region: `ap-southeast-1` functions · Adapter: `@netlify/plugin-nextjs@5.15.13` (runtime API v2, single consolidated SSR+ISR function) · Next.js 15.5.15, Node 22 + +## Summary + +Since at least July 2026 we have measured a reproducible, bimodal latency pathology on the Next.js server handler. A function instance created **in isolation** boots and serves a real database-backed page end-to-end in **~1.9s**. Instances created **under concurrent load** each stall for roughly **24 seconds with a blocked event loop before executing any application code**, then serve normally. There is nothing between the two modes: across ~90 instrumented cold renders we observed zero samples between ~6s and ~31s. + +## Evidence + +**1. Bimodality correlates exactly with instance creation count.** +Four batches against one deploy preview, client-side TTFB via curl, correlated with function logs and an in-app diagnostic route that reports per-instance id + `process.uptime()` + event-loop-lag probe: + +| Batch | Concurrency | Instance state | Samples | Result | +|---|---|---|---|---| +| A | strictly sequential | new each time | 8 | 1.80–2.72s, no outliers | +| B | 12 concurrent | ~6 pre-existing | 12 | six at 1.9–4.7s, six at 31.0–33.1s | +| C | 16 concurrent | ~12 pre-existing | 16 | twelve at 2.6–2.9s, four at 30.8–33.1s | +| D | 12 concurrent | all warm | 12 | 3.3–5.9s, zero slow | + +Slow-count equals newly-created-instance-count in every batch. The diagnostic route confirmed every stalled sample ran on an instance aged <100ms serving invocation #1. + +**2. The stall is an event-loop block BEFORE any application work.** +On stalled first invocations, a diagnostic route that awaits 400ms of idle *before* touching the database reported the idle phase taking **23.9–24.8s**, max loop lag 23.7–24.7s, while instance age was <100ms. The subsequent DB query connected in ~0.9–1.0s. On warm instances the same probe shows 400–453ms / lag 1–70ms. Downstream effects: `pg` connect timers are plain `setTimeout`s, so they fire only after the stall ends (~26s), which initially misdiagnosed this as a database problem. + +**3. Memory/CPU scaling does not touch it.** +We configured the v2 handler correctly by name (`___netlify-server-handler`; verified via `searchSiteFunctions`, field `m`) at **2048 MB** — i.e. doubled vCPU, since your docs state memory and vCPU scale together. Result under the identical 12-way burst protocol: + +| Config | Deploy id (ready UTC 2026-08-22) | commit_ref | searchSiteFunctions `m` | Result | +|---|---|---|---|---| +| control 1024 MB | `6a894a2398d6…` (07:05) / `6a895a3f4651…` (08:13) | 74f58138 / 08b10ce4 | 1024 | 11/12 slow, TTFB 27.8–31.0s | +| **treatment 2048 MB** | **`6a8954981e6f…` (07:49)** | **17228d7e** | **2048** | **11/12 slow, TTFB 35.9–37.6s + one platform 500** | +| post-revert re-run | `6a8974a11d65…` (10:06) | 0646d8f5 | 1024 | 12/12 slow, TTFB 32.6–38.0s | + +(An intermediate burst on `6a895c2e7d70…`/58fb03fc at 08:22 came back 16/16 fast — an anomaly attributable to residual warm capacity from three deploys and two concurrent agent sessions within nine minutes, not to the memory setting; recorded for completeness.) + +No improvement (possibly worse). We reverted. + +**4. Not our bundle's init work.** Sequential brand-new instances complete module loading + init + a full SSR render in <2s total, so first-invocation application work cannot account for 24s; and if the stall were proportional to per-instance init CPU, doubling CPU should have moved it. Confirmed again on 2026-08-23: a build carrying lazy-initialized payment SDK clients (#1221, deploy-preview-1221, commit 353cef1e) still stalled **12/12 at 29.5–31.5s** after ≥30 min idle, while its sequential profile was normal (first-ever request 5.84s settling to ~0.26s warm). + +**5. Observability gap:** this function emits no `Init Duration:` log line (only `Duration:`/`Memory Usage:`), so cold starts can't be discriminated from logs; we had to build an in-app instance-age probe. A forum report from May 2025 describes the same absence. + +## Questions + +1. Is concurrent instance-creation contention (e.g., simultaneous sandbox provisioning, deployment-artifact fetch, or shared-host CPU scheduling during burst scale-out) a known cause of multi-second stalls on runtime-API-v2 handlers? Is there a known incident or fix in flight since mid-2026? +2. Does Netlify have, or plan, anything equivalent to provisioned concurrency / minimum instances for framework-generated functions like `___netlify-server-handler`? Scheduled keep-warm pings keep at most one instance warm and cannot protect bursts. +3. Why does the server handler not emit AWS-style `Init Duration` in its logs, and are there plans to expose it? It makes cold-start SLO work impractical. +4. Any guidance on reducing burst-time instance-creation latency from within the deployment (bundle shape, esbuild vs default bundling, region placement), given memory/vcpu scaling showed no effect? + +## Impact + +User-visible: landing-page/explore clicks stall 20–30s then render (the "site is down" perception), worst right after deploys and during traffic bursts from a cold pool. We ship ISR-first architecture and deploy-warming workflows, but the tail persists whenever concurrency forces new instances. diff --git a/lib/payments/core/razorpay.ts b/lib/payments/core/razorpay.ts index 0badf872c..d5026ab5b 100644 --- a/lib/payments/core/razorpay.ts +++ b/lib/payments/core/razorpay.ts @@ -15,39 +15,38 @@ import { mapGatewayRefundStatus } from "@/lib/payments/refund-status"; // Razorpay Client Initialization // ============================================================================ -// L2 FIX: Removed module-load console.warn — per-call errors are more actionable -const initializeRazorpayClient = () => { - const keyId = process.env.RAZORPAY_KEY_ID; - const keySecret = process.env.RAZORPAY_SECRET; - if (!keyId || !keySecret) { - return null; - } - - // PM-10 — nothing downstream distinguishes test mode from live mode, so a - // TEST key in a production posture boots cleanly and fails only at the first - // customer: charges decline, refunds dead-end, webhooks never verify, while - // every Payment row still reads as gateway-authoritative. Fail the boot - // loudly instead. Dev / preview / test keep legitimate access to test keys — - // this fires on the production posture only. - // - // EXCEPT during `next build`: builds run with NODE_ENV=production (and CI / - // Netlify build environments legitimately hold test keys — a build moves no - // money), and this module loads while Next collects page data, so an - // unconditional throw broke every deploy preview + the CI build job. The - // guard still fires on the first real runtime boot in production, which is - // where the customer-facing failure it exists for would happen. (The - // RazorpayX payouts client carries the same guard keyed to - // ENABLE_LIVE_PAYOUTS instead of NODE_ENV — see getRazorpayPayoutsService - // in lib/payments/payouts/razorpay-payouts.ts.) - const isNextBuildPhase = - process.env.NEXT_PHASE === "phase-production-build"; +// PM-10 — nothing downstream distinguishes test mode from live mode, so a +// TEST key in a production posture boots cleanly and fails only at the first +// customer: charges decline, refunds dead-end, webhooks never verify, while +// every Payment row still reads as gateway-authoritative. Fail the boot +// loudly instead. Dev / preview / test keep legitimate access to test keys — +// this fires on the production posture only. +// +// This guard runs AT MODULE LOAD — it is an env read, not SDK construction, +// so #1221's lazy-client change keeps its cost at microseconds. Do NOT move +// it inside the lazy initializer: the fail-fast contract (razorpay-test-key- +// guard.test.ts) is that a misconfigured production posture dies at require +// time, before any route boots. +// +// EXCEPT during `next build`: builds run with NODE_ENV=production (and CI / +// Netlify build environments legitimately hold test keys — a build moves no +// money), and this module loads while Next collects page data, so an +// unconditional throw broke every deploy preview + the CI build job. The +// guard still fires on the first real runtime boot in production, which is +// where the customer-facing failure it exists for would happen. (The +// RazorpayX payouts client carries the same guard keyed to +// ENABLE_LIVE_PAYOUTS instead of NODE_ENV — see getRazorpayPayoutsService +// in lib/payments/payouts/razorpay-payouts.ts.) +{ + const guardKeyId = process.env.RAZORPAY_KEY_ID; if ( process.env.NODE_ENV === "production" && - !isNextBuildPhase && - /^rzp_test_/.test(keyId) + process.env.NEXT_PHASE !== "phase-production-build" && + guardKeyId && + /^rzp_test_/.test(guardKeyId) ) { throw new PaymentError( - `RAZORPAY_KEY_ID is set to a Razorpay TEST key (${keyId}) while NODE_ENV=production. ` + + `RAZORPAY_KEY_ID is set to a Razorpay TEST key (${guardKeyId}) while NODE_ENV=production. ` + "Live checkout, refunds and webhooks cannot run against Razorpay test mode. " + "Fix: replace RAZORPAY_KEY_ID and RAZORPAY_SECRET with the account's LIVE keys " + "(dashboard.razorpay.com → Settings → API Keys → Live mode) and redeploy.", @@ -55,6 +54,15 @@ const initializeRazorpayClient = () => { "RAZORPAY", ); } +} + +// L2 FIX: Removed module-load console.warn — per-call errors are more actionable +const initializeRazorpayClient = () => { + const keyId = process.env.RAZORPAY_KEY_ID; + const keySecret = process.env.RAZORPAY_SECRET; + if (!keyId || !keySecret) { + return null; + } return new Razorpay({ key_id: keyId, @@ -62,7 +70,20 @@ const initializeRazorpayClient = () => { }); }; -export const razorpayClient = initializeRazorpayClient(); +// Lazy singleton (lib/email.ts getResendClient convention). Instantiating at +// module scope put the SDK constructor on every cold boot of any route whose +// import graph reaches this file. MEASURED 2026-08-23 (#1221): this does NOT +// shrink the #1124 concurrent-instance event-loop stall — that reproduced +// 12/12 at full strength on a build carrying exactly this change. Keep the +// lazy pattern as boot hygiene; do not cite it as stall mitigation. +let razorpayClientInstance: Razorpay | null | undefined; + +export function getRazorpayClient(): Razorpay | null { + if (razorpayClientInstance === undefined) { + razorpayClientInstance = initializeRazorpayClient(); + } + return razorpayClientInstance; +} // ============================================================================ // SDK call timeout @@ -122,6 +143,7 @@ export async function createRazorpayOrder({ currency, metadata, }: PaymentIntentParams): Promise { + const razorpayClient = getRazorpayClient(); if (!razorpayClient) { throw new PaymentError( "Razorpay client not initialized - check RAZORPAY_KEY_ID and RAZORPAY_SECRET environment variables", @@ -177,6 +199,7 @@ export async function createRazorpayOrder({ * Cancel a Razorpay order (best effort - cannot actually cancel after payment) */ export async function cancelRazorpayOrder(orderId: string): Promise { + const razorpayClient = getRazorpayClient(); if (!razorpayClient) { console.warn("Razorpay client not initialized - cannot cancel order"); return; @@ -335,6 +358,7 @@ export async function createRazorpayRefund({ metadata, idempotencyKey, }: RefundParams): Promise { + const razorpayClient = getRazorpayClient(); if (!razorpayClient) { throw new RefundError( "Razorpay client not initialized - cannot process refund", @@ -405,6 +429,7 @@ export async function createRazorpayRefund({ export async function getRazorpayRefund( refundId: string, ): Promise { + const razorpayClient = getRazorpayClient(); if (!razorpayClient) { throw new RefundError( "Razorpay client not initialized", @@ -445,6 +470,7 @@ export async function listRazorpayRefunds( orderId: string, limit: number = 10, ): Promise { + const razorpayClient = getRazorpayClient(); if (!razorpayClient) { throw new RefundError( "Razorpay client not initialized", diff --git a/lib/payments/core/stripe.ts b/lib/payments/core/stripe.ts index 29e767f33..35aa518d8 100644 --- a/lib/payments/core/stripe.ts +++ b/lib/payments/core/stripe.ts @@ -39,7 +39,24 @@ const initializeStripeClient = () => { }); }; -export const stripeClient = initializeStripeClient(); +// Lazy singleton (lib/email.ts getResendClient convention). Instantiating at +// module scope put the SDK constructor on every cold boot of any route whose +// import graph reaches this file. MEASURED 2026-08-23 (#1221): this does NOT +// shrink the #1124 concurrent-instance event-loop stall — that reproduced +// 12/12 at full strength on a build carrying exactly this change. Keep the +// lazy pattern as boot hygiene; do not cite it as stall mitigation. +// `undefined` = not yet attempted; `null` = attempted and missing credentials +// (cached, like the original module-scope init, so the warning logs once). +// `undefined` = not yet attempted; `null` = attempted and missing credentials +// (cached, like the original module-scope init, so the warning logs once). +let stripeClientInstance: Stripe | null | undefined; + +export function getStripeClient(): Stripe | null { + if (stripeClientInstance === undefined) { + stripeClientInstance = initializeStripeClient(); + } + return stripeClientInstance; +} // ============================================================================ // Helper Functions @@ -68,6 +85,7 @@ export async function createStripeCheckoutSession({ currency, metadata, }: PaymentIntentParams): Promise { + const stripeClient = getStripeClient(); if (!stripeClient) { throw new PaymentError( "Stripe client not initialized - check STRIPE_SECRET_KEY environment variable", @@ -140,6 +158,7 @@ export async function cancelStripePayment( paymentIntentId: string, reason: string = "requested_by_customer", ): Promise { + const stripeClient = getStripeClient(); if (!stripeClient) { console.warn("Stripe client not initialized - cannot cancel payment"); return; @@ -196,6 +215,7 @@ export async function createStripeRefund({ reason, metadata, }: RefundParams): Promise { + const stripeClient = getStripeClient(); if (!stripeClient) { throw new RefundError( "Stripe client not initialized - cannot process refund", @@ -235,6 +255,7 @@ export async function createStripeRefund({ * Get refund status from Stripe */ export async function getStripeRefund(refundId: string): Promise { + const stripeClient = getStripeClient(); if (!stripeClient) { throw new RefundError( "Stripe client not initialized", @@ -267,6 +288,7 @@ export async function listStripeRefunds( paymentIntentId: string, limit: number = 10, ): Promise { + const stripeClient = getStripeClient(); if (!stripeClient) { throw new RefundError( "Stripe client not initialized", @@ -305,6 +327,7 @@ export async function listStripeRefunds( export async function getStripeDispute( disputeId: string, ): Promise { + const stripeClient = getStripeClient(); if (!stripeClient) { throw new DisputeError( "Stripe client not initialized", @@ -346,6 +369,7 @@ export async function submitStripeDisputeEvidence({ disputeId, evidence, }: DisputeParams): Promise { + const stripeClient = getStripeClient(); if (!stripeClient) { throw new DisputeError( "Stripe client not initialized", @@ -399,6 +423,7 @@ export async function submitStripeDisputeEvidence({ export async function listStripeDisputes( limit: number = 10, ): Promise { + const stripeClient = getStripeClient(); if (!stripeClient) { throw new DisputeError( "Stripe client not initialized", diff --git a/netlify.toml b/netlify.toml index 4130e2e41..80e67613c 100644 --- a/netlify.toml +++ b/netlify.toml @@ -14,6 +14,35 @@ # The standard build image has 8 GB, so this stays inside the container cap. NODE_OPTIONS = "--max-old-space-size=6144" +# ───────────────────────────────────────────────────────────────────────────── +# Function memory / #1124 stall — MEASURED DEAD AND REVERTED. Do not re-add. +# +# The stall: a brand-new instance of the Next.js server handler blocks its +# event loop ~24s before any application work whenever instances are created +# under concurrent load (#1124). Replicated 2026-08-23 after ≥30 min idle: +# 12/12 concurrent unique-key requests stalled at 29.5–31.5s. +# +# Raising handler memory was tested CORRECTLY on 2026-08-22 (2048 MB applied +# to the real v2 function name, verified via `netlify api searchSiteFunctions` +# → field `m`): 11/12 slow at 35.9–37.6s + one platform 500 vs the 1024 MB +# control's 11/12 at 27.8–31.0s. No improvement; reverted in 08b10ce4. Since +# Netlify `memory` and `vcpu` scale together, this tested CPU-share too — the +# "CPU-starved cold-boot" hypothesis is dead. A lazy-init build (lazy Razorpay/ +# Stripe SDK clients) reproduced the stall at full strength the next day: +# application init work is NOT the driver either. +# +# GOTCHA that wasted a day once: runtime API v2 (@netlify/plugin-nextjs 5.x) +# generates ONE function named ___netlify-server-handler. The classic +# ___netlify-handler / ___netlify-odb-handler names belong to v1 and match +# NOTHING — a toml block targeting them is SILENTLY IGNORED (m stayed 1024). +# Re-enumerate names after any adapter bump. +# +# What actually works (all measured): ISR cache hits pay no stall; keep-warm/ +# warm-deploy workflows (#1148) protect single clicks only; bursts cannot be +# warmed. Full record: .claude/skills/nextjs-netlify-caching/SKILL.md, +# issue #1124, docs/perf/netlify-stall-ticket-draft.md. +# ───────────────────────────────────────────────────────────────────────────── + # Skip builds for Dependabot PRs [context.deploy-preview] ignore = """