Implement custom retry handling of rate limit errors

This commit is contained in:
Dominic Ferrando
2026-07-13 16:32:20 -04:00
parent b408bb5439
commit fd94f22d21
7 changed files with 80 additions and 10 deletions
+3 -1
View File
@@ -6,5 +6,7 @@
"exports": {
".": "./src/index.ts"
},
"dependencies": {}
"dependencies": {
"@blade-and-brawn/domain": "workspace:*"
}
}
+15
View File
@@ -1,5 +1,6 @@
import type { Printful } from "./util/types";
import { type DeepPartial, type PagingOptions } from "./util/misc";
import { RateLimitError, parseRetryAfterMs } from "@blade-and-brawn/domain";
const parsePayload = (res: Response): Promise<unknown> =>
res.json().catch(() => res.text().catch(() => undefined));
@@ -26,6 +27,12 @@ export class PrintfulError extends Error {
}
}
class PrintfulRateLimitError extends RateLimitError {
constructor(retryAfterHeader: string | null, payload: unknown) {
super("Printful", parseRetryAfterMs(retryAfterHeader, 60_000), payload);
}
}
class VariantsClient {
constructor(private creds: Credentials) { }
@@ -34,6 +41,7 @@ class VariantsClient {
method: "GET",
headers: this.creds.authHeaders,
});
if (res.status === 429) throw new PrintfulRateLimitError(res.headers.get("Retry-After"), await parsePayload(res));
if (res.status === 404 || res.status === 400) return;
if (!res.ok) throw new PrintfulError("Failed to get Printful variant", res.status, await parsePayload(res));
const payload = (await res.json()) as Printful.MetaDataSingle<Printful.Products.SyncVariant>;
@@ -56,6 +64,7 @@ class ProductsClient {
method: "GET",
headers: this.creds.authHeaders,
});
if (res.status === 429) throw new PrintfulRateLimitError(res.headers.get("Retry-After"), await parsePayload(res));
if (!res.ok) throw new PrintfulError("Failed to list Printful products", res.status, await parsePayload(res));
const payload = (await res.json()) as Printful.MetaDataMulti<Printful.Products.SyncProduct>;
allSyncProducts.push(...payload.result);
@@ -70,6 +79,7 @@ class ProductsClient {
method: "GET",
headers: this.creds.authHeaders,
});
if (res.status === 429) throw new PrintfulRateLimitError(res.headers.get("Retry-After"), await parsePayload(res));
if (res.status === 404 || res.status === 400) return;
if (!res.ok) throw new PrintfulError("Failed to get Printful product", res.status, await parsePayload(res));
const payload = (await res.json()) as Printful.MetaDataSingle<Printful.Products.Product>;
@@ -85,6 +95,7 @@ class ProductsClient {
},
body: JSON.stringify(pProduct),
});
if (res.status === 429) throw new PrintfulRateLimitError(res.headers.get("Retry-After"), await parsePayload(res));
if (!res.ok) throw new PrintfulError("Failed to update Printful product", res.status, await parsePayload(res));
}
}
@@ -101,6 +112,7 @@ class OrdersClient {
},
body: JSON.stringify(pOrder),
});
if (res.status === 429) throw new PrintfulRateLimitError(res.headers.get("Retry-After"), await parsePayload(res));
if (!res.ok) throw new PrintfulError("Failed to create Printful order", res.status, await parsePayload(res));
}
@@ -112,6 +124,7 @@ class OrdersClient {
method: "GET",
headers: this.creds.authHeaders,
});
if (res.status === 429) throw new PrintfulRateLimitError(res.headers.get("Retry-After"), await parsePayload(res));
if (!res.ok) throw new PrintfulError("Failed to list Printful orders", res.status, await parsePayload(res));
const payload = (await res.json()) as Printful.MetaDataMulti<Printful.Orders.Order>;
allPOrders.push(...payload.result);
@@ -133,6 +146,7 @@ class WebhooksClient {
...this.creds.authHeaders,
}
});
if (res.status === 429) throw new PrintfulRateLimitError(res.headers.get("Retry-After"), await parsePayload(res));
if (res.status === 404 || res.status === 400) return;
if (!res.ok) throw new PrintfulError("Failed to get webhook configuration", res.status, await parsePayload(res));
const payload = (await res.json()) as Printful.Webhook.Config;
@@ -155,6 +169,7 @@ class WebhooksClient {
"types": events
}),
});
if (res.status === 429) throw new PrintfulRateLimitError(res.headers.get("Retry-After"), await parsePayload(res));
if (!res.ok) throw new PrintfulError("Failed to register webhook URL", res.status, await parsePayload(res));
}
}
+20
View File
@@ -1,6 +1,7 @@
import type { Webflow } from "./util/types";
import { type DeepPartial, type PagingOptions } from "./util/misc";
import crypto from "node:crypto";
import { RateLimitError, parseRetryAfterMs } from "@blade-and-brawn/domain";
const parsePayload = (res: Response): Promise<unknown> =>
res.json().catch(() => res.text().catch(() => undefined));
@@ -31,6 +32,12 @@ export class WebflowError extends Error {
}
}
class WebflowRateLimitError extends RateLimitError {
constructor(retryAfterHeader: string | null, payload: unknown) {
super("Webflow", parseRetryAfterMs(retryAfterHeader, 60_000), payload);
}
}
class SkusClient {
constructor(private creds: Credentials) { }
@@ -49,6 +56,7 @@ class SkusClient {
body: JSON.stringify({ skus }),
},
);
if (res.status === 429) throw new WebflowRateLimitError(res.headers.get("Retry-After"), await parsePayload(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 }[] };
return payload.skus.map((sku) => sku.id);
@@ -70,6 +78,7 @@ class SkusClient {
body: JSON.stringify({ sku: wSku }),
},
);
if (res.status === 429) throw new WebflowRateLimitError(res.headers.get("Retry-After"), await parsePayload(res));
if (!res.ok) throw new WebflowError("Failed to update Webflow product SKU", res.status, await parsePayload(res));
}
}
@@ -92,6 +101,7 @@ class ProductsClient {
},
body: JSON.stringify(wProductAndSku),
});
if (res.status === 429) throw new WebflowRateLimitError(res.headers.get("Retry-After"), await parsePayload(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 } };
return payload.product.id;
@@ -105,6 +115,7 @@ class ProductsClient {
method: "GET",
headers: this.creds.authHeader,
});
if (res.status === 429) throw new WebflowRateLimitError(res.headers.get("Retry-After"), await parsePayload(res));
if (!res.ok) throw new WebflowError("Failed to list Webflow products", res.status, await parsePayload(res));
const payload = (await res.json()) as Webflow.MetaDataMulti<"items", Webflow.Products.ProductAndSkus>;
allWProducts.push(...payload.items);
@@ -119,6 +130,7 @@ class ProductsClient {
method: "GET",
headers: this.creds.authHeader,
});
if (res.status === 429) throw new WebflowRateLimitError(res.headers.get("Retry-After"), await parsePayload(res));
if (res.status === 404 || res.status === 400) return;
if (!res.ok) throw new WebflowError("Failed to get Webflow product", res.status, await parsePayload(res));
return (await res.json()) as Webflow.Products.ProductAndSkus;
@@ -136,6 +148,7 @@ class ProductsClient {
},
body: JSON.stringify(wProduct),
});
if (res.status === 429) throw new WebflowRateLimitError(res.headers.get("Retry-After"), await parsePayload(res));
if (!res.ok) throw new WebflowError("Failed to update Webflow product", res.status, await parsePayload(res));
}
@@ -151,6 +164,7 @@ class ProductsClient {
body: JSON.stringify({}),
},
);
if (res.status === 429) throw new WebflowRateLimitError(res.headers.get("Retry-After"), await parsePayload(res));
if (!res.ok) throw new WebflowError("Failed to remove Webflow product", res.status, await parsePayload(res));
}
}
@@ -170,6 +184,7 @@ class OrdersClient {
method: "GET",
headers: this.creds.authHeader,
});
if (res.status === 429) throw new WebflowRateLimitError(res.headers.get("Retry-After"), await parsePayload(res));
if (!res.ok) throw new WebflowError("Failed to list Webflow orders", res.status, await parsePayload(res));
const payload = (await res.json()) as Webflow.MetaDataMulti<"orders", Webflow.Orders.Order>;
allOrders.push(...payload.orders);
@@ -196,6 +211,7 @@ class OrdersClient {
},
body: JSON.stringify(wOrderUpdate),
});
if (res.status === 429) throw new WebflowRateLimitError(res.headers.get("Retry-After"), await parsePayload(res));
if (!res.ok) throw new WebflowError("Failed to update Webflow order", res.status, await parsePayload(res));
}
@@ -211,6 +227,7 @@ class OrdersClient {
},
body: JSON.stringify(opt),
});
if (res.status === 429) throw new WebflowRateLimitError(res.headers.get("Retry-After"), await parsePayload(res));
if (!res.ok) throw new WebflowError("Failed to fulfill Webflow order", res.status, await parsePayload(res));
}
}
@@ -226,6 +243,7 @@ class WebhooksClient {
method: "GET",
headers: this.creds.authHeader,
});
if (res.status === 429) throw new WebflowRateLimitError(res.headers.get("Retry-After"), await parsePayload(res));
if (!res.ok) throw new WebflowError("Failed to list Webflow webhooks", res.status, await parsePayload(res));
const payload = (await res.json()) as Webflow.MetaDataMulti<"webhooks", Webflow.Webhook.Config>;
allWebhooks.push(...payload.webhooks);
@@ -250,6 +268,7 @@ class WebhooksClient {
"triggerType": event
}),
});
if (res.status === 429) throw new WebflowRateLimitError(res.headers.get("Retry-After"), await parsePayload(res));
if (!res.ok) throw new WebflowError("Failed to create Webflow webhook", res.status, await parsePayload(res));
}
@@ -265,6 +284,7 @@ class WebhooksClient {
body: JSON.stringify({}),
},
);
if (res.status === 429) throw new WebflowRateLimitError(res.headers.get("Retry-After"), await parsePayload(res));
if (!res.ok) throw new WebflowError("Failed to remove Webflow webhook", res.status, await parsePayload(res));
}
}