From c635f75dadb8f51f80835d8ce910ac81ade25466 Mon Sep 17 00:00:00 2001 From: Howard Wilson Date: Wed, 22 Jul 2026 16:30:08 +0100 Subject: [PATCH] Add experimental retry backoff for CORS-masked rate limit errors A recent Spotify API change appears to serve rate-limit (429) responses to browser clients without CORS headers, so the browser blocks them and surfaces only an opaque network error with no readable status or Retry-After. This means the existing 429 backoff logic can no longer see the server's requested wait. As an experimental mitigation, retry these headerless network errors with our own exponential backoff (1s..16s), giving up after the cycle is exhausted. --- src/helpers.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/helpers.ts b/src/helpers.ts index babe1f6a..eb7e8123 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