diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..a4ce0d7 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "bb-apartment-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000 + } + ] +} diff --git a/src/lib/beds24.ts b/src/lib/beds24.ts index 74b9e2c..1bbb524 100644 --- a/src/lib/beds24.ts +++ b/src/lib/beds24.ts @@ -66,6 +66,56 @@ function getRefreshToken(houseId: string): string | undefined { const accessTokenCache = new Map(); const pendingTokenRequests = new Map>(); +// 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(); + +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 { const refreshToken = getRefreshToken(houseId); if (!refreshToken) { @@ -84,12 +134,15 @@ async function getAccessToken(houseId: string): Promise { 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( ): Promise { 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( // (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;