Fix Beds24 429 errors by tracking real rate-limit budget from response headers.
Beds24 caps each account at ~100 credits per rolling 5-minute window rather than limiting concurrency, and a single houses-page search across 8 apartments (availability + min-stay probing) can burn through that budget in one page load. Retrying with backoff doesn't help since the window can take minutes to refill, so we now track the last known remaining budget per house and fail fast to the existing fallback logic once it's exhausted, instead of hammering the API with requests that will just 429. Also fixes a stray let->const ESLint warning in getAvailabilityInChunks.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "bb-apartment-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
+57
-1
@@ -66,6 +66,56 @@ function getRefreshToken(houseId: string): string | undefined {
|
||||
const accessTokenCache = new Map<string, { token: string; expiresAt: number }>();
|
||||
const pendingTokenRequests = new Map<string, Promise<string>>();
|
||||
|
||||
// Beds24 doesn't rate-limit by concurrency — it gives each account a hard
|
||||
// credit budget (~100 credits) that refills on a rolling 5-minute window
|
||||
// (headers `x-five-min-limit-remaining` / `x-five-min-limit-resets-in`).
|
||||
// A single availability search fans out across up to 8 apartments, each
|
||||
// needing an availability call plus getEffectiveMinStay's own binary search
|
||||
// (several sequential probes) — that can burn 50+ credits in one page load,
|
||||
// and a second search shortly after exhausts the window entirely.
|
||||
//
|
||||
// Since the window can take minutes to refill, there is no point retrying a
|
||||
// 429 within the same request — by the time it would succeed, the page
|
||||
// render has long since timed out. So instead we track the last known
|
||||
// budget per house from response headers and, once it's exhausted, skip the
|
||||
// network call entirely and fail fast so callers hit their existing
|
||||
// fallback logic immediately instead of after a failed round trip.
|
||||
interface RateLimitState {
|
||||
remaining: number;
|
||||
resetsAt: number;
|
||||
}
|
||||
const rateLimitState = new Map<string, RateLimitState>();
|
||||
|
||||
function recordRateLimitHeaders(houseId: string, response: Response) {
|
||||
const remainingHeader = response.headers.get("x-five-min-limit-remaining");
|
||||
if (remainingHeader === null) return;
|
||||
const remaining = Number(remainingHeader);
|
||||
if (!Number.isFinite(remaining)) return;
|
||||
|
||||
const resetsInHeader = response.headers.get("x-five-min-limit-resets-in");
|
||||
const resetsInSeconds = resetsInHeader !== null ? Number(resetsInHeader) : NaN;
|
||||
|
||||
rateLimitState.set(houseId, {
|
||||
remaining,
|
||||
resetsAt: Date.now() + (Number.isFinite(resetsInSeconds) ? resetsInSeconds : 300) * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
function assertRateLimitNotExhausted(houseId: string) {
|
||||
const state = rateLimitState.get(houseId);
|
||||
if (!state) return;
|
||||
if (Date.now() >= state.resetsAt) {
|
||||
rateLimitState.delete(houseId);
|
||||
return;
|
||||
}
|
||||
if (state.remaining < 1) {
|
||||
const resetsInSeconds = Math.ceil((state.resetsAt - Date.now()) / 1000);
|
||||
throw new Error(
|
||||
`Beds24 rate limit exhausted for house ${houseId}, resets in ~${resetsInSeconds}s`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function getAccessToken(houseId: string): Promise<string> {
|
||||
const refreshToken = getRefreshToken(houseId);
|
||||
if (!refreshToken) {
|
||||
@@ -84,12 +134,15 @@ async function getAccessToken(houseId: string): Promise<string> {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
assertRateLimitNotExhausted(houseId);
|
||||
|
||||
const tokenPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch(`${BEDS24_API_BASE}/authentication/token`, {
|
||||
method: "GET",
|
||||
headers: { refreshToken },
|
||||
});
|
||||
recordRateLimitHeaders(houseId, response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
@@ -124,6 +177,8 @@ async function beds24Get<T>(
|
||||
): Promise<T> {
|
||||
const token = await getAccessToken(houseId);
|
||||
|
||||
assertRateLimitNotExhausted(houseId);
|
||||
|
||||
const url = new URL(`${BEDS24_API_BASE}${path}`);
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
if (value !== undefined) url.searchParams.set(key, String(value));
|
||||
@@ -137,6 +192,7 @@ async function beds24Get<T>(
|
||||
// (longer for data that changes rarely, e.g. rate/min-stay lookups).
|
||||
next: { revalidate: revalidateSeconds },
|
||||
});
|
||||
recordRateLimitHeaders(houseId, response);
|
||||
|
||||
if (response.status === 401 && retryOnAuthFailure) {
|
||||
// Access token may be stale/revoked. Clear it and retry once.
|
||||
@@ -254,7 +310,7 @@ async function getAvailabilityInChunks(
|
||||
// chunk sizes as equal as possible. This avoids tiny final chunks that
|
||||
// would fall below a room's minimum-stay rule and return no offer.
|
||||
const numChunks = Math.ceil(totalNights / MAX_NIGHTS_PER_OFFER_REQUEST);
|
||||
let baseChunkSize = Math.floor(totalNights / numChunks);
|
||||
const baseChunkSize = Math.floor(totalNights / numChunks);
|
||||
let remainder = totalNights % numChunks;
|
||||
|
||||
let cursor = params.start;
|
||||
|
||||
Reference in New Issue
Block a user