Improve error handling

This commit is contained in:
Dominic Ferrando
2026-06-23 19:49:26 -04:00
parent 1ca0fe308d
commit 449b08971f
5 changed files with 116 additions and 137 deletions
+22 -24
View File
@@ -12,11 +12,12 @@ import { cors } from "@elysiajs/cors";
import { Elysia, NotFoundError, status } from "elysia"; import { Elysia, NotFoundError, status } from "elysia";
import { import {
PrintfulClient, PrintfulClient,
PrintfulError,
ProductSyncer, ProductSyncer,
WebflowClient, WebflowClient,
WebflowError,
Printful, Printful,
Webflow, Webflow,
FetchError,
} from "@blade-and-brawn/commerce"; } from "@blade-and-brawn/commerce";
import zipcodesUs from "zipcodes-us"; import zipcodesUs from "zipcodes-us";
import z from "zod"; import z from "zod";
@@ -66,14 +67,18 @@ export const app = new Elysia()
) )
.error({ .error({
FetchError, PrintfulError,
WebflowError,
}) })
.onError(async ({ code, error }) => { .onError(({ code, error }) => {
switch (code) { switch (code) {
case "FetchError": case "PrintfulError":
console.error(error.message, await error.parse()); case "WebflowError":
return error; console.error(error.message, error.status, error.payload);
return status(502, { error: error.message });
default:
console.error(error);
} }
}) })
@@ -130,36 +135,35 @@ export const app = new Elysia()
.post("/", async ({ }) => { .post("/", async ({ }) => {
const syncState = await productSyncer.getState(); const syncState = await productSyncer.getState();
if (syncState.isSyncing) if (syncState.isSyncing)
return status(409, { error: "Sync already in progress" }); throw status(409, "Sync already in progress");
await productSyncer.sync(); await productSyncer.sync();
return { ok: true };
}) })
// Per-product sync // Per-product sync
.post("/:printfulProductId", async ({ params }) => { .post("/:printfulProductId", async ({ params }) => {
const syncState = await productSyncer.getState(); const syncState = await productSyncer.getState();
if (syncState.isSyncing) if (syncState.isSyncing)
return status(409, { error: "Sync already in progress" }); throw status(409, "Sync already in progress");
await productSyncer.sync(params.printfulProductId); await productSyncer.sync(params.printfulProductId);
return { ok: true }; }, { params: z.object({ printfulProductId: z.coerce.number() }) }),
}, { params: z.object({ printfulProductId: z.number() }) }),
) )
// TODO: add schema validation // TODO: add schema validation
.get("/:printfulProductId", async ({ params }) => { .get("/:printfulProductId", async ({ params }) => {
const printfulProductId = +params.printfulProductId;
const printfulProduct = const printfulProduct =
await printful.Products.get(printfulProductId); await printful.Products.get(params.printfulProductId);
if (!printfulProduct) return new NotFoundError(); if (!printfulProduct) throw new NotFoundError("Missing printful product");
const webflowProductId = const webflowProductId =
printfulProduct.sync_product.external_id.split("-")[0]; printfulProduct.sync_product.external_id.split("-")[0];
if (!webflowProductId) return new NotFoundError(); if (!webflowProductId) throw new NotFoundError("Missing webflow product ID");
const webflowProduct = const webflowProduct =
await webflow.Products.get(webflowProductId); await webflow.Products.get(webflowProductId);
return { printfulProduct, webflowProduct }; return { printfulProduct, webflowProduct };
}, {
params: z.object({ printfulProductId: z.coerce.number() })
}), }),
) )
@@ -194,14 +198,10 @@ export const app = new Elysia()
break; break;
} }
} }
return { ok: true };
}) })
.post("/webhook/webflow", async ({ request, body, set }) => { .post("/webhook/webflow", async ({ request, body }) => {
if (!webflow.Util.verifyWebflowSignature(request, body)) { if (!webflow.Util.verifyWebflowSignature(request, body))
set.status = 400; throw status(400, "Invalid signature");
return "Invalid signature";
}
const payload = body as Webflow.Webhook.EventPayload; const payload = body as Webflow.Webhook.EventPayload;
@@ -237,8 +237,6 @@ export const app = new Elysia()
break; break;
} }
} }
return { ok: true };
}); });
app.listen(3000); app.listen(3000);
+21 -7
View File
@@ -1,5 +1,8 @@
import type { Printful } from "./util/types"; import type { Printful } from "./util/types";
import { FetchError, type DeepPartial } from "./util/misc"; import { type DeepPartial } from "./util/misc";
const parsePayload = (res: Response): Promise<unknown> =>
res.json().catch(() => res.text().catch(() => undefined));
type ClientOptions = { type ClientOptions = {
token: string; token: string;
@@ -11,6 +14,17 @@ type Credentials = {
authHeaders: Record<string, string>; authHeaders: Record<string, string>;
}; };
export class PrintfulError extends Error {
constructor(
message: string,
public status: number,
public payload: unknown,
) {
super(message);
this.name = "PrintfulError";
}
}
class VariantsClient { class VariantsClient {
constructor(private creds: Credentials) { } constructor(private creds: Credentials) { }
@@ -20,7 +34,7 @@ class VariantsClient {
headers: this.creds.authHeaders, headers: this.creds.authHeaders,
}); });
if (res.status === 404 || res.status === 400) return; if (res.status === 404 || res.status === 400) return;
if (!res.ok) throw new FetchError("Failed to get Printful variant", res); if (!res.ok) throw new PrintfulError("Failed to get Printful variant", res.status, await parsePayload(res));
const payload = (await res.json()) as Printful.Products.MetaDataSingle<Printful.Products.SyncVariant>; const payload = (await res.json()) as Printful.Products.MetaDataSingle<Printful.Products.SyncVariant>;
return payload.result; return payload.result;
} }
@@ -40,7 +54,7 @@ class ProductsClient {
method: "GET", method: "GET",
headers: this.creds.authHeaders, headers: this.creds.authHeaders,
}); });
if (!res.ok) throw new FetchError("Failed to get all Printful products", res); if (!res.ok) throw new PrintfulError("Failed to get all Printful products", res.status, await parsePayload(res));
const payload = (await res.json()) as Printful.Products.MetaDataMulti<Printful.Products.SyncProduct>; const payload = (await res.json()) as Printful.Products.MetaDataMulti<Printful.Products.SyncProduct>;
allSyncProducts.push(...payload.result); allSyncProducts.push(...payload.result);
offset = allSyncProducts.length; offset = allSyncProducts.length;
@@ -55,7 +69,7 @@ class ProductsClient {
headers: this.creds.authHeaders, headers: this.creds.authHeaders,
}); });
if (res.status === 404 || res.status === 400) return; if (res.status === 404 || res.status === 400) return;
if (!res.ok) throw new FetchError("Failed to get Printful product", res); if (!res.ok) throw new PrintfulError("Failed to get Printful product", res.status, await parsePayload(res));
const payload = (await res.json()) as Printful.Products.MetaDataSingle<Printful.Products.Product>; const payload = (await res.json()) as Printful.Products.MetaDataSingle<Printful.Products.Product>;
return payload.result; return payload.result;
} }
@@ -69,7 +83,7 @@ class ProductsClient {
}, },
body: JSON.stringify(printfulProduct), body: JSON.stringify(printfulProduct),
}); });
if (!res.ok) throw new FetchError("Failed to update Printful product", res); if (!res.ok) throw new PrintfulError("Failed to update Printful product", res.status, await parsePayload(res));
} }
} }
@@ -85,7 +99,7 @@ class OrdersClient {
}, },
body: JSON.stringify(printfulOrder), body: JSON.stringify(printfulOrder),
}); });
if (!res.ok) throw new FetchError("Failed to create printful order", res); if (!res.ok) throw new PrintfulError("Failed to create Printful order", res.status, await parsePayload(res));
} }
async getAll(offset = 0): Promise<Printful.Orders.Order[]> { async getAll(offset = 0): Promise<Printful.Orders.Order[]> {
@@ -95,7 +109,7 @@ class OrdersClient {
method: "GET", method: "GET",
headers: this.creds.authHeaders, headers: this.creds.authHeaders,
}); });
if (!res.ok) throw new FetchError("Failed to get all Printful orders", res); if (!res.ok) throw new PrintfulError("Failed to get all Printful orders", res.status, await parsePayload(res));
const payload = (await res.json()) as Printful.Products.MetaDataMulti<Printful.Orders.Order>; const payload = (await res.json()) as Printful.Products.MetaDataMulti<Printful.Orders.Order>;
allPrintfulOrders.push(...payload.result); allPrintfulOrders.push(...payload.result);
offset = allPrintfulOrders.length; offset = allPrintfulOrders.length;
+13 -29
View File
@@ -1,5 +1,5 @@
import type { Printful, Webflow } from "./util/types"; import type { Printful, Webflow } from "./util/types";
import { FetchError, formatSlug, type DeepPartial } from "./util/misc"; import { formatSlug, type DeepPartial } from "./util/misc";
import { WebflowClient } from "./webflow"; import { WebflowClient } from "./webflow";
import { PrintfulClient } from "./printful"; import { PrintfulClient } from "./printful";
import { redis } from "bun"; import { redis } from "bun";
@@ -31,12 +31,12 @@ export class ProductSyncer {
) { } ) { }
async sync(printfulProductId?: number) { async sync(printfulProductId?: number) {
try {
await this.setState({ isSyncing: true, syncingIds: [] }); await this.setState({ isSyncing: true, syncingIds: [] });
// Populate products // Populate products
const mainPrintfulProducts: MainProduct[] = []; const mainPrintfulProducts: MainProduct[] = [];
const webflowProducts: Webflow.Products.ProductAndSkus[] = []; const webflowProducts: Webflow.Products.ProductAndSkus[] = [];
try {
let printfulProducts = await this.printful.Products.getAll(); let printfulProducts = await this.printful.Products.getAll();
if (printfulProductId) { if (printfulProductId) {
const printfulProduct = printfulProducts.find( const printfulProduct = printfulProducts.find(
@@ -82,17 +82,12 @@ export class ProductSyncer {
}); });
mainPrintfulProduct.colors.add(productColor); mainPrintfulProduct.colors.add(productColor);
} }
} catch (error) {
await this.setState({ isSyncing: false, syncingIds: [] });
throw error;
}
// TODO: Validate main printful products // TODO: Validate main printful products
// Sync the main printful products // Sync the main printful products
console.log("Syncing main products..."); console.log("Syncing main products...");
for (const mainPrintfulProduct of mainPrintfulProducts) { for (const mainPrintfulProduct of mainPrintfulProducts) {
try {
await this.setState({ await this.setState({
isSyncing: true, isSyncing: true,
syncingIds: mainPrintfulProduct.variants.map( syncingIds: mainPrintfulProduct.variants.map(
@@ -161,9 +156,8 @@ export class ProductSyncer {
const webflowProductId = const webflowProductId =
mainPrintfulProduct.externalId.split("-")[0]; mainPrintfulProduct.externalId.split("-")[0];
if (!webflowProductId) { if (!webflowProductId)
throw new Error("Malformed printful product ID"); throw new Error("Malformed printful product ID");
}
await this.webflow.Products.update(webflowProductId, { await this.webflow.Products.update(webflowProductId, {
product: { product: {
fieldData: { fieldData: {
@@ -254,10 +248,8 @@ export class ProductSyncer {
); );
existingWebflowProduct = await this.webflow.Products.get(webflowProductId); existingWebflowProduct = await this.webflow.Products.get(webflowProductId);
if (!existingWebflowProduct) { if (!existingWebflowProduct)
console.error("Missing webflow product"); throw new Error("Webflow product missing after create");
throw new Error("Failed to create Webflow product");
}
for (const specialVariant of mainPrintfulProduct.variants) { for (const specialVariant of mainPrintfulProduct.variants) {
const newPrintfulVariants: DeepPartial<Printful.Products.SyncVariant>[] = const newPrintfulVariants: DeepPartial<Printful.Products.SyncVariant>[] =
@@ -289,15 +281,17 @@ export class ProductSyncer {
); );
} }
} }
} catch (err) {
if (err instanceof FetchError) {
console.error(err.message, err.payload);
} }
}
}
await this.setState({ isSyncing: false, syncingIds: [] });
console.log("Done"); console.log("Done");
} }
finally {
try {
await this.setState({ isSyncing: false, syncingIds: [] });
} catch {
console.error("Failed to reset sync state");
}
}
}
async setState(state: SyncState) { async setState(state: SyncState) {
await redis.set("commerce:sync:state", JSON.stringify(state)); await redis.set("commerce:sync:state", JSON.stringify(state));
@@ -323,14 +317,4 @@ export class ProductSyncer {
return productName; return productName;
} }
} }
private async getProductImageUrls(slug: string): Promise<string[]> {
const res = await fetch(`${R2_WORKER_URL}/product-images/${slug}`);
if (!res.ok) {
console.error("Failed to get product image keys:", await res.json());
throw new Error("Failed to get product image keys");
}
const productImageKeys: string[] = (await res.json()) as string[];
return productImageKeys.map((key) => `${WEBSITE_MEDIA_URL}/${key}`);
}
} }
-22
View File
@@ -13,25 +13,3 @@ export const formatSlug = (name: string): string => {
.replace(/--+/g, "-") // collapse multiple dashes .replace(/--+/g, "-") // collapse multiple dashes
.replace(/^([^a-z0-9_])/, "_$1"); // if it starts with an invalid char, prefix underscore .replace(/^([^a-z0-9_])/, "_$1"); // if it starts with an invalid char, prefix underscore
}; };
export class FetchError extends Error {
public payload: Object | undefined;
constructor(
message: string,
public response: Response,
) {
super(message);
this.name = "FetchError";
}
async parse() {
try {
this.payload = (await this.response.clone().json()) as Object;
} catch {
this.payload = await this.response.text().catch(() => undefined);
} finally {
return this.payload;
}
}
}
+37 -32
View File
@@ -1,7 +1,10 @@
import type { Webflow } from "./util/types"; import type { Webflow } from "./util/types";
import { FetchError, type DeepPartial } from "./util/misc"; import { type DeepPartial } from "./util/misc";
import crypto from "node:crypto"; import crypto from "node:crypto";
const parsePayload = (res: Response): Promise<unknown> =>
res.json().catch(() => res.text().catch(() => undefined));
type ClientOptions = { type ClientOptions = {
siteId: string; siteId: string;
collectionsId: string; collectionsId: string;
@@ -15,8 +18,19 @@ type Credentials = {
authHeader: Record<string, string>; authHeader: Record<string, string>;
}; };
export class WebflowError extends Error {
constructor(
message: string,
public status: number,
public payload: unknown,
) {
super(message);
this.name = "WebflowError";
}
}
class SkusClient { class SkusClient {
constructor(private creds: Credentials) {} constructor(private creds: Credentials) { }
async create( async create(
webflowProductId: string, webflowProductId: string,
@@ -33,7 +47,7 @@ class SkusClient {
body: JSON.stringify({ skus }), body: JSON.stringify({ skus }),
}, },
); );
if (!res.ok) throw new FetchError("Failed to create Webflow product SKU", res); if (!res.ok) throw new WebflowError("Failed to create Webflow product SKU", res.status, await parsePayload(res));
const payload = (await res.json()) as { skus: { id: string }[] }; const payload = (await res.json()) as { skus: { id: string }[] };
return payload.skus.map((sku) => sku.id); return payload.skus.map((sku) => sku.id);
} }
@@ -54,7 +68,7 @@ class SkusClient {
body: JSON.stringify({ sku: webflowSku }), body: JSON.stringify({ sku: webflowSku }),
}, },
); );
if (!res.ok) throw new FetchError("Failed to update Webflow product SKU", res); if (!res.ok) throw new WebflowError("Failed to update Webflow product SKU", res.status, await parsePayload(res));
} }
} }
@@ -76,7 +90,7 @@ class ProductsClient {
}, },
body: JSON.stringify(webflowProductAndSku), body: JSON.stringify(webflowProductAndSku),
}); });
if (!res.ok) throw new FetchError("Failed to create Webflow product", res); if (!res.ok) throw new WebflowError("Failed to create Webflow product", res.status, await parsePayload(res));
const payload = (await res.json()) as { product: { id: string } }; const payload = (await res.json()) as { product: { id: string } };
return payload.product.id; return payload.product.id;
} }
@@ -86,7 +100,7 @@ class ProductsClient {
method: "GET", method: "GET",
headers: this.creds.authHeader, headers: this.creds.authHeader,
}); });
if (!res.ok) throw new FetchError("Failed to get all Webflow products", res); if (!res.ok) throw new WebflowError("Failed to get all Webflow products", res.status, await parsePayload(res));
const payload = (await res.json()) as { items: Webflow.Products.ProductAndSkus[] }; const payload = (await res.json()) as { items: Webflow.Products.ProductAndSkus[] };
return payload.items; return payload.items;
} }
@@ -97,7 +111,7 @@ class ProductsClient {
headers: this.creds.authHeader, headers: this.creds.authHeader,
}); });
if (res.status === 404 || res.status === 400) return; if (res.status === 404 || res.status === 400) return;
if (!res.ok) throw new FetchError("Failed to get Webflow product", res); if (!res.ok) throw new WebflowError("Failed to get Webflow product", res.status, await parsePayload(res));
return (await res.json()) as Webflow.Products.ProductAndSkus; return (await res.json()) as Webflow.Products.ProductAndSkus;
} }
@@ -113,7 +127,7 @@ class ProductsClient {
}, },
body: JSON.stringify(webflowProduct), body: JSON.stringify(webflowProduct),
}); });
if (!res.ok) throw new FetchError("Failed to update Webflow product", res); if (!res.ok) throw new WebflowError("Failed to update Webflow product", res.status, await parsePayload(res));
} }
async remove(webflowProductId: string): Promise<void> { async remove(webflowProductId: string): Promise<void> {
@@ -128,12 +142,12 @@ class ProductsClient {
body: JSON.stringify({}), body: JSON.stringify({}),
}, },
); );
if (!res.ok) throw new FetchError("Failed to remove Webflow product", res); if (!res.ok) throw new WebflowError("Failed to remove Webflow product", res.status, await parsePayload(res));
} }
} }
class OrdersClient { class OrdersClient {
constructor(private creds: Credentials) {} constructor(private creds: Credentials) { }
async getAll(opt: { status?: Webflow.Orders.Order["status"] } = {}): Promise<Webflow.Orders.Order[]> { async getAll(opt: { status?: Webflow.Orders.Order["status"] } = {}): Promise<Webflow.Orders.Order[]> {
// TODO: pagination // TODO: pagination
@@ -143,7 +157,7 @@ class OrdersClient {
method: "GET", method: "GET",
headers: this.creds.authHeader, headers: this.creds.authHeader,
}); });
if (!res.ok) throw new FetchError("Failed to get all Webflow orders", res); if (!res.ok) throw new WebflowError("Failed to get all Webflow orders", res.status, await parsePayload(res));
const payload = (await res.json()) as { orders: Webflow.Orders.Order[] }; const payload = (await res.json()) as { orders: Webflow.Orders.Order[] };
return payload.orders; return payload.orders;
} }
@@ -165,7 +179,7 @@ class OrdersClient {
}, },
body: JSON.stringify(webflowOrderUpdate), body: JSON.stringify(webflowOrderUpdate),
}); });
if (!res.ok) throw new FetchError("Failed to update Webflow order", res); if (!res.ok) throw new WebflowError("Failed to update Webflow order", res.status, await parsePayload(res));
} }
async fulfill( async fulfill(
@@ -180,46 +194,37 @@ class OrdersClient {
}, },
body: JSON.stringify(opt), body: JSON.stringify(opt),
}); });
if (!res.ok) throw new FetchError("Failed to fulfill Webflow order", res); if (!res.ok) throw new WebflowError("Failed to fulfill Webflow order", res.status, await parsePayload(res));
} }
} }
class UtilClient { class UtilClient {
constructor(private webhookSecret: string) {} constructor(private webhookSecret: string) { }
verifyWebflowSignature(request: Request, body: unknown): boolean { verifyWebflowSignature(request: Request, body: unknown): boolean {
try {
const timestamp = request.headers.get("x-webflow-timestamp"); const timestamp = request.headers.get("x-webflow-timestamp");
if (!timestamp) throw new Error("No timestamp provided"); if (!timestamp) return false;
const providedSignature = request.headers.get("x-webflow-signature"); const providedSignature = request.headers.get("x-webflow-signature");
if (!providedSignature) throw new Error("No signature provided"); if (!providedSignature) return false;
if (!body) throw new Error("Body is empty"); if (!body) return false;
const requestTimestamp = parseInt(timestamp, 10); const requestTimestamp = parseInt(timestamp, 10);
if (Date.now() - requestTimestamp > 300000) return false;
const data = `${requestTimestamp}:${JSON.stringify(body)}`; const data = `${requestTimestamp}:${JSON.stringify(body)}`;
const hash = crypto const hash = crypto
.createHmac("sha256", this.webhookSecret) .createHmac("sha256", this.webhookSecret)
.update(data) .update(data)
.digest("hex"); .digest("hex");
if ( try {
!crypto.timingSafeEqual( return crypto.timingSafeEqual(
Buffer.from(hash, "hex"), Buffer.from(hash, "hex"),
Buffer.from(providedSignature, "hex"), Buffer.from(providedSignature, "hex"),
) );
) { } catch {
throw new Error("Invalid signature");
}
if (Date.now() - requestTimestamp > 300000) {
throw new Error("Request is older than 5 minutes");
}
return true;
} catch (err) {
console.error(`Error verifying signature: ${err}`);
return false; return false;
} }
} }