Merge pull request #16986 from grokability/api_throttle_headers

Fixed  #16961 - Manually add API headers
This commit is contained in:
snipe
2025-05-28 13:47:16 +01:00
committed by GitHub
5 changed files with 140 additions and 4 deletions

View File

@@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Log;
use Throwable;
use JsonException;
use Carbon\Exceptions\InvalidFormatException;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
class Handler extends ExceptionHandler
{
@@ -107,11 +108,10 @@ class Handler extends ExceptionHandler
$statusCode = $e->getStatusCode();
// API throttle requests are handled in the RouteServiceProvider configureRateLimiting() method, so we don't need to handle them here
switch ($e->getStatusCode()) {
case '404':
return response()->json(Helper::formatStandardApiResponse('error', null, $statusCode . ' endpoint not found'), 404);
case '429':
return response()->json(Helper::formatStandardApiResponse('error', null, 'Too many requests'), 429);
case '405':
return response()->json(Helper::formatStandardApiResponse('error', null, 'Method not allowed'), 405);
default:
@@ -201,6 +201,7 @@ class Handler extends ExceptionHandler
*/
public function register()
{
$this->reportable(function (Throwable $e) {
//
});

View File

@@ -52,6 +52,7 @@ class Kernel extends HttpKernel
'auth:api',
\App\Http\Middleware\CheckLocale::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\SetAPIResponseHeaders::class,
],
'health' => [

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Symfony\Component\HttpFoundation\Response;
class SetAPIResponseHeaders extends ThrottleRequests
{
/**
* Add the rate limit headers to the response.
*
* This extends the original ThrottleRequests middleware to add the 'X-RateLimit-Reset' and 'Retry-After' headers, even
* if the rate limit is not exceeded.
* @param $maxAttempts
* @param $remainingAttempts
* @param $retryAfter
* @param Response|null $response
* @return array|int[]
*/
protected function getHeaders($maxAttempts, $remainingAttempts, $retryAfter = null, ?Response $response = null)
{
if ($response &&
! is_null($response->headers->get('X-RateLimit-Remaining')) &&
(int) $response->headers->get('X-RateLimit-Remaining') <= (int) $remainingAttempts) {
$headers = [];
$headers['Retry-After'] = $retryAfter; // this is the only line we changed
$headers['X-RateLimit-Reset'] = $retryAfter; // this is the only line we changed
$headers['X-RateLimit-Reset-Timestamp'] = $this->availableAt($retryAfter); // this is the only line we changed
return $headers;
}
$headers = [
'X-RateLimit-Limit' => $maxAttempts,
'X-RateLimit-Remaining' => $remainingAttempts,
];
if (! is_null($retryAfter)) {
$headers['Retry-After'] = $retryAfter;
$headers['X-RateLimit-Reset'] = $retryAfter; // this is the only line we changed
$headers['X-RateLimit-Reset-Timestamp'] = $this->availableAt($retryAfter); // this is the only line we changed
}
return $headers;
}
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
protected function handleRequest($request, Closure $next, array $limits)
{
foreach ($limits as $limit) {
if ($this->limiter->tooManyAttempts($limit->key, $limit->maxAttempts)) {
throw $this->buildException($request, $limit->key, $limit->maxAttempts, $limit->responseCallback);
}
$this->limiter->hit($limit->key, $limit->decaySeconds);
}
$response = $next($request);
foreach ($limits as $limit) {
$response = $this->addHeaders(
$response,
$limit->maxAttempts,
$this->calculateRemainingAttempts($limit->key, $limit->maxAttempts),
$this->getTimeUntilNextRetry($limit->key) // this is the only line we changed
);
}
return $response;
}
}

View File

@@ -76,6 +76,8 @@ class RouteServiceProvider extends ServiceProvider
/**
* Configure the rate limiters for the application.
*
* This ONLY fires on 429 responses.
*
* https://laravel.com/docs/8.x/routing#rate-limiting
*
* @return void
@@ -83,9 +85,17 @@ class RouteServiceProvider extends ServiceProvider
protected function configureRateLimiting()
{
// Rate limiter for API calls
// Rate limiter for API calls - this sends the correct API headers to show the user the remaining time they have to wait and gives them the 429 status code if they are throttled
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(config('app.api_throttle_per_minute'))->by(optional($request->user())->id ?: $request->ip());
return Limit::perMinute(config('app.api_throttle_per_minute'))->by(optional($request->user())->id ?: $request->ip())
->response(function ($request, $headers) {
return response()->json([
'status' => 'error',
'messages' => 'Too many requests. Try again in '.$headers['Retry-After'].' seconds.',
'status_code' => 429,
'retryAfter' => $headers['Retry-After'] ?? 60,
], 429, $headers);
});
});
// Rate limiter for forgotten password requests

View File

@@ -0,0 +1,42 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Tests\TestCase;
class ApiRateLimitTest extends TestCase
{
public function testRateLimit()
{
config(['app.api_throttle_per_minute' => 10]);
$this->actingAsForApi(User::factory()->create())
->getJson(route('api.users.me'))
->assertOk()
->assertHeader('X-Ratelimit-Limit', config('app.api_throttle_per_minute'))
->assertHeader('X-Ratelimit-Remaining', 9);
}
public function testRateLimitDecreasesRemaining()
{
config(['app.api_throttle_per_minute' => 5]);
$expected_remaining = (config('app.api_throttle_per_minute') - 1);
$admin = User::factory()->create();
for ($x = 0; $x < 5; $x++) {
$this->actingAsForApi($admin)
->getJson(route('api.users.me'))
->assertOk()
->assertHeader('X-Ratelimit-Remaining', $expected_remaining--);
}
$this->actingAsForApi($admin)
->getJson(route('api.users.me'))
->assertStatus(429)
->assertHeader('Retry-After', 60);
}
}