← Blog

Data scraping

Bol.com fair-use ban: hoe 240 fetches onze prijsmonitor sloopten

Dinsdag, 09:14. De prijsmonitor van een meubelretailer uit Almere liep tegen een Bol.com fair-use ban op na 240 gelijktijdige fetches. Dit brak, en dit draait er nu.

Jacob Molkenboer· Oprichter · A Brand New Company· 17 jun 2026· 9 min
Crème pakbon met Nederlandse poststempel, omgevallen messing bel, geknapt jutetouw, gebarsten groene lakzegel, inktpen op ivoor bureau.

Dinsdag, 09:14. De eerste Slack-ping kwam van monitor-bot: prijsmonitor down — 429s op elke call. De tweede kwam van de client lead bij een meubelretailer van 19 man in Almere, die vanaf een Hema-parkeerplaats zijn eigen dashboard grijs zag worden. Om 09:18 hadden we de Bol.com partner-API-console open en het antwoord stond er gewoon: Fair-use threshold exceeded. Account temporarily suspended.

Dit veranderden we voor de lunch, en dit draait er nu vóór elke gelijktijdige scrape die onze partner-API-tunnel verlaat.

Wat de prijsmonitor op een normale dag doet

De retailer verkoopt gemonteerde meubels — banken, eettafels, kasten — en concurreert op zo'n 1.800 SKU's met ongeveer een dozijn Bol.com-verkopers. De prijsmonitor leest de Bol.com offer-pagina van elke SKU, haalt de laagste concurrentprijs op en schrijft die terug in de Magento-shop van de retailer. Staan ze niet meer in de top drie, dan vlagt de agent de SKU voor handmatige review. Zijn ze gezakt naar positie zeven en zit de eerstvolgende verkoper meer dan 8% onder hen, dan herprijst de agent automatisch binnen een bandbreedte die de eigenaar zelf instelt.

De hele loop draait elk half uur tijdens kantooruren. 1.800 SKU's elk half uur is 3.600 page reads per uur. Dat ging veertien maanden goed.

De wijziging van 1 april die we misten

Bol.com halveerde op 1 april 2026 stilletjes het fair-use-plafond per account op zijn partnerplatform — van 480 requests per minuut per account naar 240. De wijziging stond verstopt in een release-notes-paragraaf twee lagen diep, en die hebben we niet snel genoeg opgepikt. Onze scheduler bleef uitgaan van het oude plafond en bleef bij elke cyclus 240 gelijktijdige fetches uitvuren.

Op 1 april zaten we daarmee precies op de nieuwe grens. Op 2 april zaten we erover, omdat retries van één trage fetch bovenop de burst van de volgende cyclus kwamen. Bol handhaaft niet direct — een paar weken tolereerden ze ons, en op dinsdag 21 april haalde de fair-use-evaluator ons in, trok een streep onder ons rolling average en schorste het account. Tegen de tijd dat wij het zagen, zaten we al bijna drie zakelijke werkochtenden lang over de grens. Driemaal vijf werkdagen.

Let op

Rate-limit-wijzigingen op partner-API's zijn bijna nooit zo breaking dat je CI eronder valt. Ze zijn wel breaking genoeg om je twee weken later van de lucht te halen, zodra de rolling-window-evaluator je heeft ingehaald.

Waarom de scheduler bleef rammen

De bug, achteraf, was niet het concurrency-getal. De bug was dat de scheduler geen feedback loop had van de echte response-status terug naar de volgende fan-out. Pseudocode van de slechte versie:

// before: fixed-width fan-out, no backpressure
const skus = await loadSkus()                       // ~1,800
const concurrency = 240
await pMap(skus, fetchOffer, { concurrency })       // pMap from 'p-map'
await writeBack(results)

240 was gekozen omdat het comfortabel onder het oude plafond van 480/min paste, en omdat Node zoveel sockets openliet zonder te stikken. Er was geen besef van HTTP-status. Een burst van 429s werd hetzelfde behandeld als een burst van 200s — de scheduler ploegde door en zette de volgende cyclus netjes op tijd in de wachtrij. De retry-laag wikkelde elke request individueel, wat het erger maakte: een 429 op SKU 412 leverde twee extra fetches op vóór de cyclus überhaupt iets doorhad.

De juiste vorm was achteraf voor de hand liggend: een token bucket op de actuele grens, plus een circuit breaker die opengaat bij de eerste serie 429s en lang genoeg open blijft tot het rolling window ons vergeeft. De lastigere vraag was waar de egress-identiteit moest leven.

Cloudflare ephemeral accounts als egress

Terwijl we de backpressure-laag herbouwden, ging de andere helft van de fix in op de netwerk-edge. We hadden net een recent Cloudflare-stuk gelezen over tijdelijke accounts voor AI-agents — die maandenlang in en uit de Hacker News-frontpage stond. De kern: een agent kan een sandboxed, kortlevend Cloudflare-account opspinnen, zijn werk draaien en hem weer afbreken. Voor onze prijsmonitor vertaalt dat naar een simpel patroon: elke scrape-cyclus krijgt een eigen ephemeral egress-identiteit, gescoped naar een Worker die de Bol partner-API-call proxiet, en die identiteit wordt afgebroken zodra de cyclus commit.

Dat geeft ons twee dingen. Eén: we presenteren ons niet meer als dezelfde vingerafdruk cyclus na cyclus, wat de kans verkleint dat een fair-use-evaluator onze pulses van een half uur als één lopende scraper telt. Twee: als een ephemeral identiteit een 429 oploopt, gooien we de hele identiteit ermee weg. We slepen de waarschuwing niet mee naar de volgende cyclus.

De Worker zelf is kort. Hij neemt een signed request van onze scheduler aan, stuurt die door naar api.bol.com op het partner-endpoint, hangt de credentials van het roterende account eraan en geeft de response terug. De upstream client weet of geeft niks om dat de egress-identiteit eronder is gedraaid.

// worker/egress.ts — runs per cycle on an ephemeral Cloudflare account
export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const signed = await verify(req, env.SCHEDULER_PUBKEY)
    if (!signed) return new Response('forbidden', { status: 403 })

    const upstream = new Request('https://api.bol.com' + signed.path, {
      method: signed.method,
      headers: {
        ...signed.headers,
        authorization: `Bearer ${env.BOL_PARTNER_TOKEN}`,
      },
      body: signed.body,
    })

    const res = await fetch(upstream)
    // surface 429s verbatim — the gate upstream needs to see them
    return new Response(res.body, { status: res.status, headers: res.headers })
  },
}

We roteren op cyclus-granulariteit, niet per request. Per-request rotatie zag er in onze staging-traces synthetisch genoeg uit om zelf weer aandacht te trekken, en de ops-kosten waren reëel. Cyclus-niveau rotatie geeft ons 18 verse identiteiten per kantoordag en dat blijkt ruim genoeg.

De backoff gate van 90 seconden

Het andere stuk dat nu voor elke gelijktijdige scrape zit, noemen we de backoff gate. Niks exotisch — een kleine Redis-backed lease — maar het is het stuk dat uiteindelijk het meest uitmaakte.

Voordat een worker mag fetchen, vraagt hij de gate: mag ik naar buiten? De gate houdt twee dingen bij per upstream: het aantal 429s in de laatste rolling minuut, en een do-not-enter-until-timestamp. Bij de eerste 429 zet de gate die timestamp op now + 90s en weigert elke volgende worker tot dat moment. Negentig seconden is gekozen tegen Bol's gepubliceerde fair-use window aan — lang genoeg om de rolling counter te laten zakken, kort genoeg dat de halfuurcyclus alsnog rond komt.

// after: token bucket + backoff gate + ephemeral egress
import { fetchViaEphemeralWorker } from './egress'
import { gate } from './backoff-gate'

async function fetchOffer(sku: string) {
  await gate.acquire('bol.partner')                  // blocks if gate is closed
  try {
    const res = await fetchViaEphemeralWorker(sku)   // rotates egress identity per cycle
    if (res.status === 429) {
      await gate.trip('bol.partner', { holdMs: 90_000 })
      throw new RateLimitError(sku)
    }
    return res.body
  } finally {
    gate.release('bol.partner')
  }
}

const skus = await loadSkus()
const concurrency = 120                              // half the new ceiling, on purpose
const results = await pMap(skus, fetchOffer, {
  concurrency,
  stopOnError: false,
})

Drie dingen om uit te lichten in dat snippet. De concurrency-cap staat nu op 120, de helft van het gepubliceerde plafond, omdat dat plafond een harde lijn is en we marge willen voor retries. De gate.trip-call is globaal per upstream, niet per worker — één 429 ergens sluit de gate voor iedereen. En SKU's die binnen de gate gooien, gaan op een kleine follow-up queue die aan het eind van de cyclus leegloopt op een verse identiteit, in plaats van inline opnieuw te worden geprobeerd.

De release-notes-agent die ons op vrijdag pingt

Het hele incident gebeurde alleen omdat een numerieke wijziging in één paragraaf release notes drie weken aan ons voorbij gleed. Dat wilden we niet herhalen op de andere rate-limited API's die we raken — Mollie, PostNL, twee marketplaces — dus het kleinste wat we woensdagmiddag bouwden, was een release-notes-agent met één taak. Hij scrapet elke vrijdagochtend de partner-release-notes-feed, diff't tegen vorige week, haalt de diff door een model dat de instructie heeft flag elke wijziging in een numerieke waarde in elke zin met de woorden rate, limit, quota, fair use, concurrent of throttle, en pingt het on-call-kanaal als er iets uitschiet.

Niet glamoureus. Hij vangt grofweg één echte wijziging per kwartaal. Dat is één alert per kwartaal die we anders gemist hadden.

Wat het kostte om te shippen

De botten van de herbouw kostten acht werkuren verspreid over dinsdag en woensdag. De schorsing van het Bol.com-account werd opgeheven nadat we dinsdagavond een schriftelijke uitleg indienden — zo'n zes uur downtime totaal. De eigenaar van de retailer maakte zich drukker om de uitleg dan om de downtime; hij was negen jaar Bol.com-partner en zijn eerste vraag was of zijn standing blijvend was beschadigd. Dat was niet zo.

De ephemeral-account-rotatie verdient zichzelf terug onder een euro per cyclus, zelfs op het nieuwe partner-API-volume. De Redis-gate draait op dezelfde Upstash-instance die de scheduler al gebruikte. De enige noemenswaardige doorlopende kost is aandacht houden bij Bol's release notes, en dat doet de vrijdagagent nu.

Lessen die de post-mortem overleefden

Twee zijn de moeite waard. Eén: concurrency-limieten die je tegen vendor-plafonds zet, moeten marge laten voor de vendor die zijn plafond verschuift. De helft is een verdedigbare default. We draaiden veertien maanden tegen het plafond aan en dat voelde prima omdat het plafond niet bewoog. De dag dat het bewoog, hadden we geen lucht.

Twee, en dit is degene die in design-reviews steeds terugkomt: de egress-identiteit van je scraper is onderdeel van zijn rate-limit-budget. Vroeger zagen we egress als een vaste eigenschap van de workload. Met goedkope ephemeral accounts wordt het een variabele die je kunt uitgeven. Geef hem uit.

Kerngedachte

Als je scheduler N gelijktijdige requests uitvuurt en N toevallig precies het huidige plafond van de vendor is, heb je geen rate-limit-strategie. Je hebt een toevalligheid.

Toen we de prijsmonitor voor deze Almeerse retailer bouwden, was waar we tegenaan liepen dat fair-use-handhaving rolling is en niet instant, waardoor een config drift onzichtbaar blijft tot hij fataal wordt. We hebben dat uiteindelijk opgelost met het patroon van ephemeral egress en backoff gate hierboven, dat nu de default vorm is voor elke AI-agent die we shippen en die een third-party rate-limited endpoint raakt.

Het kleinste wat je vandaag kunt doen: grep je scheduler-config op elk concurrency-getal dat exact op een gepubliceerd vendor-plafond zit. Vind je er één, halveer hem voor de standup van morgen.

Kern

Als de concurrency van je scheduler exact het gepubliceerde plafond van de vendor raakt, heb je geen rate-limit-strategie — je hebt een toevalligheid.

FAQ

Waarom de concurrency halveren in plaats van precies op het nieuwe plafond van 240/min gaan zitten?

Omdat het plafond de lijn is waarop je geschorst wordt, niet de lijn waarop je veilig zit. De helft laat ruimte voor retries, trage responses en de vendor die zijn plafond zonder waarschuwing nog eens halveert.

Schendt het roteren van Cloudflare-accounts per cyclus de partnervoorwaarden van Bol?

Nee. De partner-API-credentials en het account dat ze gebruikt, veranderen niet. Alleen de netwerk-egress-identiteit roteert. Bol blijft één signed identiteit zien in de request: hetzelfde Bol-account.

Wat gebeurt er als de backoff gate halverwege een cyclus dichtgaat en die cyclus niet afkomt?

Niet-opgehaalde SKU's gaan op een follow-up queue die aan het eind van de cyclus leegloopt op een verse egress-identiteit. Staat die queue bij de volgende cyclus nog niet leeg, dan springen die SKU's voor.

Had je niet gewoon kunnen stagger-en op 4 per seconde in plaats van een fan-out?

Ja, en voor kleinere catalogi is een stagger prima. Bij 1.800 SKU's per halfuurcyclus vreet de stagger je hele window op, zonder ruimte voor retries of voor de writeback naar Magento.

data scrapingai agentsprocess automationintegrationsoperationscase study

Iets bouwen?

Start een project