Shopify staat 40 verzoeken per seconde toe. Bij bulkoperaties zit je daar snel overheen. Hoe het bucket algoritme werkt en hoe je retry logic bouwt die het respecteert.
Shopify API rate limiting afhandelen vanuit Laravel
40 verzoeken per seconde. Dat is de Shopify REST API limiet voor een Plus-winkel. Voor een standaard winkel is het 2 verzoeken per seconde. Wie bulkoperaties uitvoert — producten synchroniseren, orders ophalen, voorraad bijwerken — zit daar snel overheen.
Het resultaat: 429-fouten, gefaalde syncs, inconsistente data. Dit artikel legt uit hoe het rate limiting systeem van Shopify werkt en hoe je er een robuuste implementatie omheen bouwt in Laravel.
Wat je leert in dit artikel
- Hoe Shopify's leaky bucket algoritme werkt
- REST vs GraphQL rate limiting: de verschillen
- Hoe je retry logic bouwt met exponential backoff
- Hoe je bulkoperaties veilig uitvoert via de GraphQL Bulk API
- Een complete implementatie in Laravel
Het leaky bucket algoritme
Shopify gebruikt geen simpele "X verzoeken per minuut" limiet. Het gebruikt een leaky bucket algoritme. Dat werkt als volgt:
- Elke winkel heeft een bucket met een maximale capaciteit (40 voor Plus, 80 voor de meeste REST endpoints)
- Elk API-verzoek kost 1 punt uit de bucket
- De bucket vult zich bij met 2 punten per seconde (REST) of wordt gereset na elke query (GraphQL)
- Als de bucket leeg is, krijg je een 429
De Shopify API stuurt de huidige bucketstatus mee in de response headers:
X-Shopify-Shop-Api-Call-Limit: 32/40
Retry-After: 2.0
De eerste header vertelt je hoeveel van de bucket je al hebt verbruikt. De tweede vertelt je hoeveel seconden je moet wachten na een 429.
Dit is de informatie die je implementatie nodig heeft.
REST vs GraphQL rate limiting
De twee API-varianten hebben verschillende limieten.
REST API:- Standaard winkels: 2 verzoeken/seconde (bucket van 40)
- Plus winkels: 4 verzoeken/seconden (bucket van 80)
- Endpoints als
/orders.jsontellen elk als 1 verzoek
- Geen bucket-systeem maar een "cost"-systeem
- Elke query heeft een berekende cost op basis van de complexiteit
- Maximale cost per query: 1.000 punten
- Maximale cost per seconde: 50 punten
- Response header:
X-GraphQL-Cost-Include-Fields
Voor grote datasynchronisaties is de GraphQL Bulk Operations API de betere keuze. Die heeft geen rate limit — je stuurt één mutation, Shopify verwerkt asynchroon en stuurt een webhook als het klaar is.
Een Laravel implementatie
De basis: een Shopify API client met rate limit bewustzijn
// app/Services/Shopify/ShopifyClient.php
namespace App\Services\Shopify;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ShopifyClient
{
private PendingRequest $http;
private float $bucketCapacity = 40.0;
private float $currentBucket = 40.0;
private float $leakRate = 2.0; // punten per seconde
private float $lastRequestTime;
public function __construct(
private readonly string $shopDomain,
private readonly string $accessToken,
) {
$this->http = Http::baseUrl("https://{$shopDomain}/admin/api/2024-01")
->withHeaders([
'X-Shopify-Access-Token' => $accessToken,
'Content-Type' => 'application/json',
])
->timeout(30);
$this->lastRequestTime = microtime(true);
}
public function get(string $endpoint, array $params = []): Response
{
return $this->request('GET', $endpoint, $params);
}
public function post(string $endpoint, array $data = []): Response
{
return $this->request('POST', $endpoint, $data);
}
public function put(string $endpoint, array $data = []): Response
{
return $this->request('PUT', $endpoint, $data);
}
private function request(string $method, string $endpoint, array $data = []): Response
{
$this->throttle();
$response = retry(
times: 5,
callback: function (int $attempt) use ($method, $endpoint, $data) {
$response = $this->http->$method($endpoint, $data);
$this->updateBucketFromResponse($response);
if ($response->status() === 429) {
$retryAfter = (float) ($response->header('Retry-After') ?? 2.0);
Log::warning('Shopify rate limit bereikt', [
'endpoint' => $endpoint,
'attempt' => $attempt,
'retry_after' => $retryAfter,
]);
// Gooi een exception zodat retry() weet dat hij moet wachten
throw new ShopifyRateLimitException($retryAfter);
}
$response->throw(); // Gooi andere HTTP errors
return $response;
},
sleepMilliseconds: function (int $attempt, \Exception $e): int {
if ($e instanceof ShopifyRateLimitException) {
// Wacht exact wat Shopify zegt
return (int) ($e->retryAfter * 1000);
}
// Exponential backoff voor andere fouten
return min(1000 * pow(2, $attempt - 1), 30000);
}
);
return $response;
}
private function throttle(): void
{
$now = microtime(true);
$elapsed = $now - $this->lastRequestTime;
// Bereken hoeveel de bucket is bijgevuld
$this->currentBucket = min(
$this->bucketCapacity,
$this->currentBucket + ($elapsed * $this->leakRate)
);
// Als de bucket bijna leeg is, wacht dan proactief
if ($this->currentBucket < 5) {
$waitSeconds = (5 - $this->currentBucket) / $this->leakRate;
usleep((int) ($waitSeconds * 1_000_000));
$this->currentBucket = 5;
}
$this->currentBucket -= 1;
$this->lastRequestTime = microtime(true);
}
private function updateBucketFromResponse(Response $response): void
{
$callLimit = $response->header('X-Shopify-Shop-Api-Call-Limit');
if ($callLimit) {
[$used, $capacity] = explode('/', $callLimit);
$this->currentBucket = (float) $capacity - (float) $used;
$this->bucketCapacity = (float) $capacity;
}
}
}
De exception klasse
// app/Services/Shopify/ShopifyRateLimitException.php
namespace App\Services\Shopify;
class ShopifyRateLimitException extends \RuntimeException
{
public function __construct(public readonly float $retryAfter)
{
parent::__construct("Shopify rate limit bereikt. Wacht {$retryAfter} seconden.");
}
}
Bulksynchronisatie: de juiste aanpak
Voor het ophalen van grote datasets (10.000+ orders, volledige productcatalog) is de REST API niet geschikt. Je zou honderden verzoeken nodig hebben.
Gebruik de GraphQL Bulk Operations API. Je stuurt één mutation, Shopify verwerkt alles asynchroon, en je haalt het resultaat op via een URL in een webhook.
// app/Services/Shopify/ShopifyBulkOperations.php
namespace App\Services\Shopify;
class ShopifyBulkOperations
{
public function __construct(private readonly ShopifyGraphQL $graphql) {}
public function startOrdersExport(string $sinceDate): string
{
$mutation = <<<'GRAPHQL'
mutation {
bulkOperationRunQuery(
query: """
{
orders(query: "created_at:>SINCE_DATE") {
edges {
node {
id
name
createdAt
totalPriceSet {
shopMoney {
amount
currencyCode
}
}
lineItems {
edges {
node {
title
quantity
sku
}
}
}
}
}
}
}
"""
) {
bulkOperation {
id
status
}
userErrors {
field
message
}
}
}
GRAPHQL;
// Vervang placeholder met echte datum
$mutation = str_replace('SINCE_DATE', $sinceDate, $mutation);
$response = $this->graphql->query($mutation);
return $response['data']['bulkOperationRunQuery']['bulkOperation']['id'];
}
public function checkStatus(string $operationId): array
{
$query = <<<GRAPHQL
{
node(id: "{$operationId}") {
... on BulkOperation {
id
status
errorCode
objectCount
fileSize
url
partialDataUrl
}
}
}
GRAPHQL;
return $this->graphql->query($query)['data']['node'];
}
public function downloadAndProcess(string $fileUrl, callable $processor): void
{
// Shopify levert het resultaat als JSONL (JSON Lines)
$stream = Http::get($fileUrl)->body();
foreach (explode("\n", trim($stream)) as $line) {
if (empty($line)) {
continue;
}
$record = json_decode($line, true);
$processor($record);
}
}
}
Het webhook endpoint voor bulkoperaties
// Webhook handler voor 'bulk_operations/finish'
public function handleBulkOperationFinished(array $payload): void
{
$operationId = $payload['admin_graphql_api_id'];
$status = $payload['status'];
$fileUrl = $payload['url'] ?? null;
if ($status !== 'COMPLETED' || ! $fileUrl) {
Log::error('Bulk operation mislukt', ['payload' => $payload]);
return;
}
ProcessBulkOperationResult::dispatch($operationId, $fileUrl);
}
Concurrent requests: pas op voor race conditions
Als je meerdere queue workers parallel Shopify-verzoeken laat doen, verbruiken ze samen de bucket. Eén worker weet niet wat de andere doet.
Oplossing: gebruik een distributed rate limiter via Redis.
// app/Services/Shopify/DistributedRateLimiter.php
namespace App\Services\Shopify;
use Illuminate\Support\Facades\Redis;
class DistributedRateLimiter
{
private string $key;
public function __construct(string $shopDomain)
{
$this->key = "shopify_bucket:{$shopDomain}";
}
public function acquire(): bool
{
// Lua script voor atomische bucket-operatie
$script = <<<'LUA'
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local leak_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now
-- Bereken nieuwe tokens op basis van verstreken tijd
local elapsed = now - last_refill
tokens = math.min(capacity, tokens + (elapsed * leak_rate))
if tokens >= cost then
tokens = tokens - cost
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 60)
return 1
end
return 0
LUA;
$result = Redis::eval(
$script,
1,
$this->key,
40, // capacity
2, // leak_rate per seconde
microtime(true),
1 // cost van dit verzoek
);
return $result === 1;
}
public function waitAndAcquire(int $maxWaitMs = 10000): void
{
$waited = 0;
$interval = 100; // ms
while (! $this->acquire()) {
usleep($interval * 1000);
$waited += $interval;
if ($waited >= $maxWaitMs) {
throw new \RuntimeException('Rate limiter timeout na ' . $maxWaitMs . 'ms');
}
}
}
}
GraphQL cost tracking
GraphQL heeft een ander systeem. Elke query heeft een cost. Houd dit bij in je queries.
// GraphQL query met cost tracking
private function executeWithCostTracking(string $query): array
{
$response = $this->client->post('/graphql.json', ['query' => $query]);
$data = $response->json();
// Shopify geeft cost informatie terug in extensions
$cost = $data['extensions']['cost'] ?? null;
if ($cost) {
$requestedQueryCost = $cost['requestedQueryCost'];
$throttleStatus = $cost['throttleStatus'];
$currentlyAvailable = $throttleStatus['currentlyAvailable'];
$restoreRate = $throttleStatus['restoreRate'];
Log::debug('GraphQL cost tracking', [
'requested_cost' => $requestedQueryCost,
'currently_available' => $currentlyAvailable,
'restore_rate' => $restoreRate,
]);
// Proactief vertragen als budget laag is
if ($currentlyAvailable < 100) {
$waitSeconds = (100 - $currentlyAvailable) / $restoreRate;
usleep((int) ($waitSeconds * 1_000_000));
}
}
return $data;
}
Best practices samengevat
| Situatie | Aanpak |
|---|---|
| Enkel verzoek | Gewone REST client met retry |
| Paginering (< 10.000 records) | REST met throttle middleware |
| Grote datasets (> 10.000 records) | GraphQL Bulk Operations |
| Meerdere workers | Distributed rate limiter via Redis |
| Real-time updates | Webhooks, geen polling |
Conclusie
Rate limiting is geen bijzaak. Het is de randvoorwaarde voor een stabiele Shopify-integratie. Wie het negeert, bouwt een systeem dat bij elke bulk-operatie kapot gaat.
De oplossing is gelaagd: een client die de bucket-headers leest, proactieve throttling voor normale requests, en Bulk Operations voor grote datasets. Met een distributed limiter werkt het ook stabiel bij meerdere concurrent workers.
Meer weten over Shopify-integraties? Bekijk onze Shopify diensten of Laravel diensten. Lees ook ons artikel over webhooks betrouwbaar verwerken — rate limiting en webhooks gaan hand in hand bij een robuuste Shopify-koppeling. De officiële Shopify API rate limit documentatie is een goede referentie voor de meest actuele limieten.
Hulp nodig bij je Shopify-integratie? Neem contact op.
Veelgestelde vragen
Wat is het verschil in rate limits tussen een standaard en Plus winkel?Standaard winkels hebben een bucket van 40 en vullen bij met 2/seconde. Plus winkels hebben een bucket van 80 en vullen bij met 4/seconde. Dit geldt voor de REST API. GraphQL heeft een ander cost-gebaseerd systeem dat hetzelfde is voor beide typen.
Kan ik de rate limit verhogen?Niet direct. Shopify Plus biedt hogere limieten automatisch. Voor uitzonderlijke situaties (grote migraties) kun je contact opnemen met Shopify Support voor tijdelijke verhogingen, maar dat is geen gegarandeerde optie.
Is GraphQL altijd beter dan REST?Niet per se. GraphQL is beter voor queries waarbij je veel gerelateerde data in één verzoek wilt ophalen. REST is soms eenvoudiger voor enkelvoudige resource-operaties. Voor bulkdata is de GraphQL Bulk Operations API duidelijk de winnaar.
Hoe test ik rate limiting lokaal?Shopify heeft een development store waarmee je de API kunt testen. Rate limits gelden ook voor development stores, maar zijn makkelijker te bereiken met gesimuleerde load. Gebruik Laravel's Http::fake() voor unit tests van je retry logica.
Wat doe ik als mijn sync altijd vastloopt op rate limits?Controleer eerst of je de bucket-headers leest en bijhoudt. Als dat al goed is, overweeg dan de GraphQL Bulk Operations API voor grote datasets. Voor real-time sync: gebruik webhooks in plaats van polling — dat is goedkoper in API-kosten en betrouwbaarder.

Geschreven door Ruthger Idema
15+ jaar ervaring in e-commerce development. Gespecialiseerd in Magento, Shopify en Laravel maatwerk.
Meer over ons team →