diff --git a/src/helpers.ts b/src/helpers.ts index babe1f6..eb7e812 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -6,12 +6,27 @@ import { clearAccessToken } from "./auth" const REQUEST_RETRY_BUFFER = 1000 const MAX_RATE_LIMIT_RETRIES = 2 // 3 attempts in total const MAX_ERROR_RETRIES = 2 // 3 attempts in total +const MAX_NETWORK_RETRIES = 5 // 6 attempts in total — throttle recovery on large playlists +const NETWORK_RETRY_BASE = 1000 // First backoff delay; doubles each subsequent retry const limiter = new Bottleneck({ maxConcurrent: 1, minTime: 0 }) limiter.on("failed", async (error, jobInfo) => { + // A rate-limited (429) response arrives without CORS headers, so the browser blocks it and + // surfaces only an opaque network error (ERR_NETWORK) with no `error.response`. That means + // we can't read the 429's `Retry-After` to honour Spotify's requested wait. As a fallback, + // apply our own exponential backoff — Spotify's own guidance when rate limited is to slow + // down and retry. If the errors persist through the whole backoff cycle we stop retrying and + // let the error propagate (which aborts the export) rather than hanging indefinitely. + if (error.response == null) { + if (jobInfo.retryCount < MAX_NETWORK_RETRIES) { + return NETWORK_RETRY_BASE * Math.pow(2, jobInfo.retryCount) // 1s, 2s, 4s, 8s, 16s + } + return undefined + } + if (error.response.status === 429 && jobInfo.retryCount < MAX_RATE_LIMIT_RETRIES) { // Retry according to the indication from the server with a small buffer return ((error.response.headers["retry-after"] || 1) * 1000) + REQUEST_RETRY_BUFFER