Shopify API rate limiting afhandelen vanuit Laravel
Terug naar blog

Shopify API rate limiting afhandelen vanuit Laravel

AuthorRuthger Idema
19 maart 202612 min leestijd

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.json tellen elk als 1 verzoek
GraphQL Admin API:
  • 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

php
// 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

php
// 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.

php
// 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

php
// 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.

php
// 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.

php
// 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

SituatieAanpak
Enkel verzoekGewone REST client met retry
Paginering (< 10.000 records)REST met throttle middleware
Grote datasets (> 10.000 records)GraphQL Bulk Operations
Meerdere workersDistributed rate limiter via Redis
Real-time updatesWebhooks, 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.

Ruthger Idema

Geschreven door Ruthger Idema

15+ jaar ervaring in e-commerce development. Gespecialiseerd in Magento, Shopify en Laravel maatwerk.

Meer over ons team →
Deel dit artikel:

Wil je jouw e-commerce naar het volgende niveau?

Plan een vrijblijvend gesprek met onze experts over Magento, Shopify of Laravel maatwerk.

Plan een Tech Check