From 3e44053a242795764c84b6951ba97ed673d5f77d Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Tue, 04 Aug 2026 18:08:17 +0000 Subject: [PATCH] Harden endpoint failures caught by PostHog --- system/backend/stripe-product.mjs | 18 ++++++++++++++++++ system/netlify/functions/flux.mjs | 40 ++++++++++++++++++++++++++++++++++++---- system/netlify/functions/index.mjs | 25 +++++++++++++++++++------ system/netlify/functions/ticket.js | 37 +++++++++++++++++++++++++++---------- system/public/aesthetic.computer/disks/see.mjs | 2 ++ system/tests/browser-compatibility.test.mjs | 24 ++++++++++++++++++++++++ system/tests/flux.test.mjs | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ system/tests/stripe-product.test.mjs | 41 +++++++++++++++++++++++++++++++++++++++++ 8 file(s) changed, 226 insertion(s)(+), 20 deletion(s)(-) diff --git a/system/backend/stripe-product.mjs b/system/backend/stripe-product.mjs new file mode 100644 --- /dev/null +++ b/system/backend/stripe-product.mjs @@ -0,0 +1,18 @@ +function stripeId(value) { + if (typeof value === "string") return value; + if (value && typeof value.id === "string") return value.id; + return null; +} + +function subscriptionProductId(subscription) { + return stripeId(subscription?.items?.data?.[0]?.price?.product); +} + +function invoiceProductId(invoice) { + const line = invoice?.lines?.data?.[0]; + return stripeId( + line?.pricing?.price_details?.product ?? line?.price?.product, + ); +} + +export { invoiceProductId, stripeId, subscriptionProductId }; diff --git a/system/netlify/functions/flux.mjs b/system/netlify/functions/flux.mjs --- a/system/netlify/functions/flux.mjs +++ b/system/netlify/functions/flux.mjs @@ -20,6 +20,27 @@ import { respond } from "../../backend/http.mjs"; const FLUX_URL = "https://ai.api.nvidia.com/v1/genai/black-forest-labs/flux.1-schnell"; +const FLUX_TIMEOUT_MS = 30000; +const FLUX_OUTAGE_COOLDOWN_MS = 60000; + +let outageUntil = 0; + +function temporarilyUnavailable(retryAfterMs = FLUX_OUTAGE_COOLDOWN_MS) { + const retryAfter = Math.max(1, Math.ceil(retryAfterMs / 1000)); + return respond( + 503, + { ok: false, reason: "temporarily_unavailable", retry_after: retryAfter }, + { "Retry-After": String(retryAfter) }, + ); +} + +function openOutageCircuit(now = Date.now()) { + outageUntil = now + FLUX_OUTAGE_COOLDOWN_MS; +} + +export function resetFluxOutageCircuit() { + outageUntil = 0; +} // Two filter-safe AC style suffixes. The bisect that pinned these down lives // in ~/Desktop/nvidia-flux-log/README.md — short version: NVIDIA's safety @@ -87,11 +108,16 @@ const seed = Number.isInteger(body.seed) ? body.seed : Math.floor(Math.random() * 1e9); + const now = Date.now(); + if (outageUntil > now) { + return temporarilyUnavailable(outageUntil - now); + } + // 30s timeout — FLUX schnell normally returns in 1-4s. NVIDIA has been // observed hanging for minutes before 504'ing during outages; fail fast // so the piece can show an error and let the user retry. const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 30000); + const timeoutId = setTimeout(() => controller.abort(), FLUX_TIMEOUT_MS); const t0 = Date.now(); let upstream; @@ -116,10 +142,13 @@ signal: controller.signal, }); } catch (err) { if (err.name === "AbortError") { - return respond(504, { ok: false, reason: "timeout" }); + openOutageCircuit(); + console.warn("flux: upstream timed out; outage circuit opened"); + return temporarilyUnavailable(); } console.error("flux: upstream fetch failed", err); - return respond(502, { ok: false, reason: "network", detail: err.message }); + openOutageCircuit(); + return temporarilyUnavailable(); } finally { clearTimeout(timeoutId); } @@ -127,11 +156,14 @@ if (!upstream.ok) { const detail = await upstream.text().catch(() => ""); console.error("flux: upstream", upstream.status, detail.slice(0, 300)); + if (upstream.status === 429 || upstream.status >= 500) { + openOutageCircuit(); + return temporarilyUnavailable(); + } return respond(502, { ok: false, reason: "upstream", status: upstream.status, - detail: detail.slice(0, 300), }); } diff --git a/system/netlify/functions/index.mjs b/system/netlify/functions/index.mjs --- a/system/netlify/functions/index.mjs +++ b/system/netlify/functions/index.mjs @@ -104,12 +104,25 @@ try { // TODO: Return a 500 or 404 for everything that does not exist... // - [] Like for example if the below import fails... - if ( - event.path === "/favicon.ico" || - event.path === "/requestProvider.js.map" - ) { - return { statusCode: 500 }; - } + if (event.path === "/favicon.ico") { + return { + statusCode: 302, + headers: { + "Cache-Control": "public, max-age=86400", + "Content-Type": "text/plain; charset=utf-8", + Location: "/purple-pals.svg", + }, + body: "", + }; + } + + if (event.path === "/requestProvider.js.map") { + return { + statusCode: 404, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + body: "Not found", + }; + } // Serve system .mjs files directly as static assets // These get caught by the catch-all redirect but shouldn't go through piece loading diff --git a/system/netlify/functions/ticket.js b/system/netlify/functions/ticket.js --- a/system/netlify/functions/ticket.js +++ b/system/netlify/functions/ticket.js @@ -7,11 +7,16 @@ import { respond } from "../../backend/http.mjs"; import { email } from "../../backend/email.mjs"; import { connect } from "../../backend/database.mjs"; import * as KeyValue from "../../backend/kv.mjs"; -import { - productId as sotceNetProductId, - SOTCE_NET_SMTP_PASS, - SOTCE_NET_SMTP_USER, -} from "../../backend/sotce-net-constants.mjs"; +import { + productId as sotceNetProductId, + SOTCE_NET_SMTP_PASS, + SOTCE_NET_SMTP_USER, +} from "../../backend/sotce-net-constants.mjs"; +import { + invoiceProductId, + stripeId, + subscriptionProductId, +} from "../../backend/stripe-product.mjs"; const dev = process.env.CONTEXT === "dev"; // 💲 A utility function to calculate the order amount @@ -302,11 +307,23 @@ // active after a record update. const subscription = hookEvent.data.object; - // Retrieve the invoice to get the product information - const invoice = await stripe.invoices.retrieve( - subscription.latest_invoice, - ); - const productId = invoice.lines.data[0].price.product; + // Stripe moved invoice-line product data from `price.product` to + // `pricing.price_details.product`. Prefer the subscription item, then + // retrieve the invoice only when the webhook does not include it. + let productId = subscriptionProductId(subscription); + const latestInvoiceId = stripeId(subscription.latest_invoice); + + if (!productId && latestInvoiceId) { + const invoice = await stripe.invoices.retrieve(latestInvoiceId); + productId = invoiceProductId(invoice); + } + + if (!productId) { + console.warn("ticket: subscription update has no product; ignored"); + return respond(200, { + message: "Webhook ignored: subscription product unavailable", + }); + } if (productId === sotceNetProductId) { console.log("🟢 Subscription update for `sotce-net`."); diff --git a/system/public/aesthetic.computer/disks/see.mjs b/system/public/aesthetic.computer/disks/see.mjs --- a/system/public/aesthetic.computer/disks/see.mjs +++ b/system/public/aesthetic.computer/disks/see.mjs @@ -70,6 +70,8 @@ : data.reason === "no_key" ? "server has no NVIDIA_API_KEY" : data.reason === "timeout" ? "timed out — NVIDIA may be slow, tap to retry" + : data.reason === "temporarily_unavailable" + ? `NVIDIA is unavailable — retry in ${data.retry_after || 60}s` : data.reason === "upstream" ? "NVIDIA error — tap to retry" : `error: ${data.reason || "unknown"}`; diff --git a/system/tests/browser-compatibility.test.mjs b/system/tests/browser-compatibility.test.mjs new file mode 100644 --- /dev/null +++ b/system/tests/browser-compatibility.test.mjs @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { handler } from "../netlify/functions/index.mjs"; + +test("favicon.ico redirects to the platform SVG", async () => { + const response = await handler({ + path: "/favicon.ico", + headers: {}, + queryStringParameters: {}, + }); + + assert.equal(response.statusCode, 302); + assert.equal(response.headers.Location, "/purple-pals.svg"); +}); + +test("missing requestProvider source map is a 404", async () => { + const response = await handler({ + path: "/requestProvider.js.map", + headers: {}, + queryStringParameters: {}, + }); + + assert.equal(response.statusCode, 404); +}); diff --git a/system/tests/flux.test.mjs b/system/tests/flux.test.mjs new file mode 100644 --- /dev/null +++ b/system/tests/flux.test.mjs @@ -0,0 +1,59 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + handler, + resetFluxOutageCircuit, +} from "../netlify/functions/flux.mjs"; + +const originalFetch = globalThis.fetch; +const originalKey = process.env.NVIDIA_API_KEY; + +test.afterEach(() => { + globalThis.fetch = originalFetch; + if (originalKey === undefined) delete process.env.NVIDIA_API_KEY; + else process.env.NVIDIA_API_KEY = originalKey; + resetFluxOutageCircuit(); +}); + +test("opens a short outage circuit after an upstream timeout", async () => { + process.env.NVIDIA_API_KEY = "test-key"; + let requests = 0; + globalThis.fetch = async () => { + requests += 1; + throw new DOMException("timed out", "AbortError"); + }; + + const event = { + httpMethod: "POST", + body: JSON.stringify({ prompt: "a square", preset: "raw" }), + }; + const first = await handler(event); + const second = await handler(event); + + assert.equal(first.statusCode, 503); + assert.equal(first.headers["Retry-After"], "60"); + assert.equal(JSON.parse(first.body).reason, "temporarily_unavailable"); + assert.equal(second.statusCode, 503); + assert.equal(requests, 1); +}); + +test("client errors remain visible without opening the outage circuit", async () => { + process.env.NVIDIA_API_KEY = "test-key"; + let requests = 0; + globalThis.fetch = async () => { + requests += 1; + return new Response("bad request", { status: 422 }); + }; + + const event = { + httpMethod: "POST", + body: JSON.stringify({ prompt: "a square", preset: "raw" }), + }; + const first = await handler(event); + const second = await handler(event); + + assert.equal(first.statusCode, 502); + assert.equal(JSON.parse(first.body).status, 422); + assert.equal(second.statusCode, 502); + assert.equal(requests, 2); +}); diff --git a/system/tests/stripe-product.test.mjs b/system/tests/stripe-product.test.mjs new file mode 100644 --- /dev/null +++ b/system/tests/stripe-product.test.mjs @@ -0,0 +1,41 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + invoiceProductId, + stripeId, + subscriptionProductId, +} from "../backend/stripe-product.mjs"; + +test("reads a product from a subscription item", () => { + assert.equal( + subscriptionProductId({ + items: { data: [{ price: { product: "prod_subscription" } }] }, + }), + "prod_subscription", + ); +}); + +test("reads current and legacy Stripe invoice-line product shapes", () => { + assert.equal( + invoiceProductId({ + lines: { + data: [ + { pricing: { price_details: { product: "prod_current" } } }, + ], + }, + }), + "prod_current", + ); + assert.equal( + invoiceProductId({ + lines: { data: [{ price: { product: "prod_legacy" } }] }, + }), + "prod_legacy", + ); +}); + +test("normalizes expanded Stripe objects and missing data", () => { + assert.equal(stripeId({ id: "prod_expanded" }), "prod_expanded"); + assert.equal(subscriptionProductId({ items: { data: [] } }), null); + assert.equal(invoiceProductId({ lines: { data: [] } }), null); +}); -- tangled.sh