Laravel 11: ограничение скорости выполнения заданий, которое не должно превышать ограничение скорости внешнего API

поскольку очереди Laravel 11 теперь также могут иметь ограничение скорости (https://laravel.com/docs/11.x/queues#rate-limiting). Мое приложение Laravel выполняет несколько запросов к API Shopify для получения новых продуктов, добавления примечаний к некоторым заказам, а также добавления к заказу информации о доставке, такой как номер отслеживания.

У меня есть план Shopify Basic, по которому разрешено делать максимум 2 запроса в секунду, но не более 40 запросов в минуту.

Теперь я хочу написать универсальное задание, которое я смогу использовать для вызовов API Shopify. Итак, либо я получаю продукты, либо обновляю продукты, я хочу иметь один класс заданий, который позаботится об этом, чтобы я мог быть уверен, что не превышаю ограничение скорости Shopify API.

Однако я не могу выполнить эту работу. Я определил ограничение скорости в своем AppServiceProvider.php, как описано в документации Laravel:

public function boot(): void
{
    // Rate limit Shopify API requests set to 2 per second and 40 per minute
    RateLimiter::for('shopify-api-requests', function (object $job) {
        return [
            Limit::perSecond(2),
            Limit::perMinute(40),
        ];
    });
}

Это мой повторно используемый класс задания, который я хочу использовать повторно для каждого запроса, который я делаю к Shopify API:

class ShopifyApiRequestJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $endpoint;
    public $method;
    public $data;
    
    public function __construct(string $endpoint, string $method = 'GET', array $data = null)
    {
        $this->endpoint = $endpoint;
        $this->method = $method;
        $this->data = $data;
    }
    
    public function backoff(): array
    {
        return [1, 5, 10];
    }
    
    public function tries(): int
    {
        return 3;
    }
    
    public function middleware(): array
    {
        return [
            new RateLimited('shopify-api-requests'),
            //new WithoutOverlapping('shopify-api-requests')
        ];
    }
    
    public function handle()
    {
        // Construct the full URL
        $url = 'https://' . config('settings.SHOPIFY_API_DOMAIN') . '/admin/api/' . config('settings.SHOPIFY_API_VERSION') . '/' . $this->endpoint;

        $response = Http::withHeaders([
            'X-Shopify-Access-Token' => config('settings.SHOPIFY_API_KEY'),
            'Content-Type' => 'application/json',
        ])->{$this->method}($url, $this->data);

        // Handle the response as needed (e.g., log it, store it, etc.)
        if ($response->failed()) {
            // Handle failure (e.g., retry the job, log the error, etc.)
            Log::error("Shopify API Request Failed (" . $response->status() . "): " . $response->body() . " " . $url);
        } else {
            // Handle success (e.g., process the response, store it, etc.)
            Log::info("Shopify API Request Successful");
        }
    }
}

Когда я тестирую свой класс работы, он ведет себя не так, как ожидалось. Я создал цикл foreach и отправил задание 10 раз.

Ожидаемый результат, который я пытаюсь заархивировать, заключается в том, что каждое отправленное задание не перекрывается с другим заданием того же класса (ShopifyApiRequestJob), и в секунду обрабатывается максимум 2 задания, а в минуту - максимум 30 заданий.

Однако в итоге я получаю такой журнал:

[2024-08-24 19:29:11] local.INFO: Shopify API Request Successful  
[2024-08-24 19:29:17] local.INFO: Shopify API Request Successful  
[2024-08-24 19:29:20] local.INFO: Shopify API Request Successful  
[2024-08-24 19:29:26] local.ERROR: App\Jobs\ShopifyApiRequestJob has been attempted too many times. {"exception":"[object] (Illuminate\\Queue\\MaxAttemptsExceededException(code: 0): [...]
[2024-08-24 19:29:26] local.ERROR: App\Jobs\ShopifyApiRequestJob has been attempted too many times. {"exception":"[object] (Illuminate\\Queue\\MaxAttemptsExceededException(code: 0): [...]
[2024-08-24 19:29:26] local.ERROR: App\Jobs\ShopifyApiRequestJob has been attempted too many times. {"exception":"[object] (Illuminate\\Queue\\MaxAttemptsExceededException(code: 0): [...]
[2024-08-24 19:29:26] local.ERROR: App\Jobs\ShopifyApiRequestJob has been attempted too many times. {"exception":"[object] (Illuminate\\Queue\\MaxAttemptsExceededException(code: 0): [...]
[2024-08-24 19:29:26] local.ERROR: App\Jobs\ShopifyApiRequestJob has been attempted too many times. {"exception":"[object] (Illuminate\\Queue\\MaxAttemptsExceededException(code: 0): [...]
[2024-08-24 19:29:26] local.ERROR: App\Jobs\ShopifyApiRequestJob has been attempted too many times. {"exception":"[object] (Illuminate\\Queue\\MaxAttemptsExceededException(code: 0): [...]
[2024-08-24 19:29:26] local.ERROR: App\Jobs\ShopifyApiRequestJob has been attempted too many times. {"exception":"[object] (Illuminate\\Queue\\MaxAttemptsExceededException(code: 0): [...]

Три задания обрабатываются успешно, но все остальные 7 заданий завершаются сбоем из-за исключения MaxAttemptsExceededException. Я специально увеличил время $backOff для его отладки, но безуспешно.

Я не понимаю, что я сделал не так, настраивая свою работу. Я следил за документацией. Кто-нибудь может дать мне совет, как решить эту проблему?

Кроме того, я хотел бы получать уведомление, если все повторные попытки задания оказались неудачными, а не при каждой повторной попытке.

Кто-нибудь знает, как заархивировать такое поведение?

С уважением

🤔 А знаете ли вы, что...
PHP предоставляет множество инструментов для отладки кода, таких как Xdebug.


1
50
2

Ответы:

Чтобы эффективно обрабатывать ограничения скорости Shopify API с помощью системы очередей Laravel, вам необходимо учесть несколько ключевых моментов в вашей реализации.

Пожалуйста, выполните следующие действия и надеюсь, что проблема решена:

Конфигурация ограничения первой скорости:

AppServiceProvider Конфигурация

use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
    RateLimiter::for('shopify-api-requests', function () {
        return [
            Limit::perSecond(2)->by('shopify-api'),
            Limit::perMinute(40)->by('shopify-api'),
        ];
    });
}

Вторая Job конфигурация

Класс работы

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Redis\Limit;

class ShopifyApiRequestJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $endpoint;
    public $method;
    public $data;

    public function __construct(string $endpoint, string $method = 'GET', array $data = null)
    {
        $this->endpoint = $endpoint;
        $this->method = $method;
        $this->data = $data;
    }

    public function backoff(): array
    {
        return [2, 10, 20]; // Adjusted backoff times
    }

    public function tries(): int
    {
        return 5; // Increase tries to accommodate retries within the rate limits
    }

    public function middleware(): array
    {
        return [
            new RateLimited('shopify-api-requests'),
        ];
    }

    public function handle()
    {
        // Construct the full URL
        $url = 'https://' . config('settings.SHOPIFY_API_DOMAIN') . '/admin/api/' . config('settings.SHOPIFY_API_VERSION') . '/' . $this->endpoint;

        $response = Http::withHeaders([
            'X-Shopify-Access-Token' => config('settings.SHOPIFY_API_KEY'),
            'Content-Type' => 'application/json',
        ])->{$this->method}($url, $this->data);

        // Handle the response
        if ($response->failed()) {
            // Log failure
            Log::error("Shopify API Request Failed (" . $response->status() . "): " . $response->body() . " " . $url);

            // If all retries fail, log a separate error
            if ($this->attempts() >= $this->tries()) {
                Log::critical("All retries for Shopify API Request Failed: " . $url);
            }

            // Re-throw the exception to allow Laravel to handle retries
            throw new \Exception("Shopify API request failed.");
        } else {
            // Log success
            Log::info("Shopify API Request Successful");
        }
    }
}

Третья обработка Retries и Notifications

Обработка сбоев в работе

use Illuminate\Support\Facades\Notification;
use App\Notifications\JobFailedNotification;

public function failed(\Exception $exception)
{
    // Notify the admin when all retries have failed
    Notification::route('mail', config('settings.ADMIN_EMAIL'))
        ->notify(new JobFailedNotification($this->endpoint, $exception));
}

Решено

Я столкнулся с аналогичной проблемой, связанной с ограничением скорости с внешним API.

Можете ли вы удалить метод tries и добавить метод retryUntil?

public function retryUntil(): \DateTime
{
    return now()->addMinutes(Illuminate\Support\Carbon::MINUTES_PER_HOUR * 2);
}

Вот реализация My Job

<?php

namespace App\Jobs\Report\PrtgReport;

use App\Models\User;
use DateTime;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

class SendExternalRequest implements ShouldQueue
{
    use Batchable;
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    /**
     * The number of seconds the job can run before timing out.
     *
     * @var int
     */
    public $timeout = 120;

    /**
     * Create a new job instance.
     *
     * @return void
     */
    public function __construct(User $user)
    {
        //
    }

    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle()
    {
        $response = Http::baseUrl('mybaseurl.com')
            ->get('test.php', []);

        if ($response->successful()) {
            //logic Goes here

        } else {
            $this->release(10);
        }
    }

    /**
     * Get the middleware the job should pass through.
     *
     * @return array<int, object>
     */
    public function middleware(): array
    {
        return [
            new APIRateLimiterMiddleware(),

            (new WithoutOverlapping(User::class.':HIT:'.$this->user->id))
                ->releaseAfter(10),
        ];
    }

    /**
     * Determine the time at which the job should timeout.
     */
    public function retryUntil(): DateTime
    {
        return now()->addMinutes(Carbon::MINUTES_PER_HOUR * 2);
    }
}

И вот промежуточное программное обеспечение, которое я использую

<?php

namespace App\Jobs\Middleware;

use App\Models\User;
use Closure;
use Illuminate\Support\Facades\Redis;

class APIRateLimiterMiddleware
{
    /**
     * Process the queued job.
     *
     * @param  \Closure(object): void  $next
     */
    public function handle(object $job, Closure $next): void
    {
        Redis::throttle('API'.':Screenshot')
            ->block(0)
            ->allow(1)
            ->every(3) // Allow 1 job every 3 seconds
            ->then(function () use ($job, $next) {
                // Lock obtained...
                $next($job);
            }, function () use ($job) {
                // Could not obtain lock...
                $job->release(3); // Release the job back to the queue after 3 seconds
            });
    }
}

Предоставленный мной код - это всего лишь образец моей реализации, пожалуйста. обязательно приспособьтесь к вашим требованиям.