Skip to content

Rate Limiting & Circuit Breaker

Retail-API implements rate limiting at multiple levels to protect against abuse and ensure fair resource usage. Additionally, a circuit breaker pattern protects against cascading failures at locality level.

ComponentLocationPurpose
ThrottleRequestsLaravel built-inRequest rate limiting
RouteServiceProviderapp/Providers/Defines api and webhooks rate limiters
RateLimiterServiceProviderapp/Providers/Defines security-related rate limiters
RequestCircuitBreakerapp/Http/Middleware/Locality-based circuit breaker
CircuitBreakerHelperapp/Http/Controllers/Support/Circuit breaker state management

File: app/Providers/RouteServiceProvider.php

Defines the main rate limiters for API groups.

private function configureRateLimiting(): void
{
RateLimiter::for('api', function (Request $request): Limit {
return Limit::perMinute(600)->by((string) ($request->user()->id ?? $request->ip() ?? '127.0.0.1'));
});
RateLimiter::for('webhooks', function (Request $request): Limit {
return Limit::perMinute(600)->by($request->ip() ?? '127.0.0.1');
});
}

File: app/Providers/RateLimiterServiceProvider.php

Defines security-related rate limiters.

final class RateLimiterServiceProvider extends ServiceProvider
{
public function boot(): void
{
// OTP verification
RateLimiter::for('otp-verify', function (Request $request): Limit {
return Limit::perHour(10)->by($request->ip());
});
// OTP resend (multiple limits)
RateLimiter::for('otp-resend', function (Request $request): array {
return [
Limit::perHour(10)->by($request->ip()),
Limit::perMinute(1)->by($request->ip()),
];
});
// WebAuthn authentication
RateLimiter::for('webauthn-authentication', function (Request $request): array {
$max_attempts = config('webauthn.rate_limit.authentication_max_attempts');
return [
Limit::perHour($max_attempts['per_hour'])->by($request->ip()),
Limit::perMinute($max_attempts['per_min'])->by($request->ip()),
];
});
// WebAuthn registration
RateLimiter::for('webauthn-registration', function (Request $request): array {
$max_attempts = config('webauthn.rate_limit.registration_max_attempts');
return [
Limit::perHour($max_attempts['per_hour'])->by($request->user()?->id),
Limit::perMinute($max_attempts['per_min'])->by($request->user()?->id),
];
});
}
}
LimiterLimitsKey ByUse Case
api600/minUser ID or IP (fallback: 127.0.0.1)Default API group
webhooks600/minIP (fallback: 127.0.0.1)Webhook endpoints
otp-verify10/hourIPOTP verification
otp-resend10/hour + 1/minIPOTP resend (anti-spam)
webauthn-authenticationconfigurableIPWebAuthn login
webauthn-registrationconfigurableUser ID (nullable)WebAuthn credential creation

File: Laravel’s Illuminate\Routing\Middleware\ThrottleRequests

app/Http/Kernel.php
protected $middlewareGroups = [
'api' => [
ThrottleRequests::class . ':api', // Use 'api' rate limiter
// ...
],
'webhooks' => [
ThrottleRequests::class . ':webhooks',
// ...
],
'internal' => [
ThrottleRequests::class . ':120,1', // 120 requests per minute (inline)
// ...
],
];
// Use named limiter
Route::post('otp/verify', [OtpController::class, 'verify'])
->middleware('throttle:otp-verify');
Route::post('otp/resend', [OtpController::class, 'resend'])
->middleware('throttle:otp-resend');
// WebAuthn with nested middleware
Route::group(['middleware' => 'prevent_privileged_impersonation'], function () {
Route::post('webauthn/registration/initialize', ...)
->middleware('throttle:webauthn-registration');
});
// Inline definition (simple cases)
Route::post('simple-action', [SimpleController::class, 'store'])
->middleware('throttle:60,1'); // 60 requests per minute

When rate limiting is active, responses include:

X-RateLimit-Limit: 600
X-RateLimit-Remaining: 599
X-RateLimit-Reset: 1699999999
{
"message": "Too Many Attempts."
}

HTTP Status: 429 Too Many Requests

Headers:

Retry-After: 60
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1699999999

Some actions require multiple limits (burst + sustained):

RateLimiter::for('otp-resend', function (Request $request): array {
return [
// Sustained limit: 10 per hour
Limit::perHour(10)->by($request->ip()),
// Burst limit: 1 per minute
Limit::perMinute(1)->by($request->ip()),
];
});

This prevents:

  • Spam attacks (burst limit)
  • Long-term abuse (sustained limit)

Both limits are checked independently. If either limit is exceeded, the request is rejected.

Request 1: OK (hour: 1/10, minute: 1/1)
Request 2 (within 1 min): REJECTED (minute: exceeded)
Request 2 (after 1 min): OK (hour: 2/10, minute: 1/1)
...
Request 11 (within 1 hour): REJECTED (hour: exceeded)

File: app/Http/Middleware/RequestCircuitBreaker.php

Protects the system from cascading failures when a locality (household/advisor/organization) is causing problems.

final class RequestCircuitBreaker
{
public function handle(Request $request, Closure $next): Response
{
$method = $request->method();
$circuit_status = LocalityManager::switchIgnoreLicenseStatus(
IGNORE_LICENSE_STATUS,
fn (): array => CircuitBreakerHelper::getLocalityCircuitStatus(LocalityManager::locality(), $method),
);
if ($circuit_status['is_broken']) {
// Create error message with reason
$message = 'Service is temporarily unavailable';
if ($circuit_status['reason'] !== null) {
$message .= ': ' . $circuit_status['reason'];
}
// Let the global error handler deal with the response formatting
throw new HttpException(429, $message);
}
return $next($request);
}
}
  • Uses LocalityManager to get current locality context
  • Checks circuit status for the specific HTTP method being requested
  • Error message: “Service is temporarily unavailable: {reason}”
  • Throws HttpException(429) when circuit is broken

File: app/Http/Controllers/Support/CircuitBreakerHelper.php

Manages circuit breaker state in Redis cache.

final class CircuitBreakerHelper
{
public const string LEVEL_HOUSEHOLD = 'household';
public const string LEVEL_ADVISOR = 'advisor';
public const string LEVEL_ORGANIZATION = 'organization';
public const array HTTP_METHODS = [
Request::METHOD_GET,
Request::METHOD_POST,
Request::METHOD_PUT,
Request::METHOD_DELETE,
];
private const int DEFAULT_CIRCUIT_BREAK_TTL = 300; // 5 minutes
private const string CIRCUIT_BREAKER_CACHE_KEY_PREFIX = 'circuit_breaker:';
public static function breakCircuitGivenResourceAndMethod(
string $resource_type,
int $resource_id,
string $reason,
array|null $methods = null,
int|null $ttl = null,
): void {
$cache_key = self::buildCacheKey($resource_type, $resource_id);
$methods ??= self::HTTP_METHODS;
Cache::store('redis')->put(
$cache_key,
[
'reason' => $reason,
'methods' => $methods,
],
$ttl ?? self::DEFAULT_CIRCUIT_BREAK_TTL,
);
}
public static function getLocalityCircuitStatus(Locality $locality, string|null $method = null): array
{
// Checks hierarchy based on locality type:
// HouseholdLocality: household → advisor → organization
// AdvisorLocality: advisor → organization
// OrganizationLocality: organization
// Returns: ['is_broken' => bool, 'reason' => string|null, 'level' => string|null, 'id' => int|null, 'methods' => array]
}
}

The circuit breaker checks a hierarchy based on locality type:

Locality TypeCheck Order
HouseholdLocalityhousehold → advisor → organization
AdvisorLocalityadvisor → organization
OrganizationLocalityorganization
circuit_breaker:household:123
circuit_breaker:advisor:456
circuit_breaker:organization:789
[
'reason' => 'Calculation overload',
'methods' => ['GET', 'POST', 'PUT', 'DELETE'], // Which methods are blocked
]
  • 300 seconds (5 minutes)
// Block all methods for a household
CircuitBreakerHelper::breakCircuitGivenResourceAndMethod(
resource_type: CircuitBreakerHelper::LEVEL_HOUSEHOLD,
resource_id: $household->id,
reason: 'Calculation overload',
);
// Block only POST and PUT for an advisor
CircuitBreakerHelper::breakCircuitGivenResourceAndMethod(
resource_type: CircuitBreakerHelper::LEVEL_ADVISOR,
resource_id: $advisor->id,
reason: 'Too many write operations',
methods: ['POST', 'PUT'],
ttl: 120, // 2 minutes
);
// Reset all methods
CircuitBreakerHelper::resetCircuitGivenResourceAndMethod(
resource_level: CircuitBreakerHelper::LEVEL_HOUSEHOLD,
resource_id: $household->id,
);
// Reset specific methods only
CircuitBreakerHelper::resetCircuitGivenResourceAndMethod(
resource_level: CircuitBreakerHelper::LEVEL_ADVISOR,
resource_id: $advisor->id,
methods: ['POST'],
);

{
"message": "Service is temporarily unavailable: Calculation overload"
}

HTTP Status: 429 Too Many Requests


// Per-user (authenticated endpoints)
Limit::perMinute(100)->by($request->user()->id);
// Per-IP (public endpoints)
Limit::perMinute(30)->by($request->ip());
// Composite key (granular control)
Limit::perMinute(50)->by($request->user()->id . '|' . $request->ip());
// Per-resource (specific resource protection)
Limit::perMinute(10)->by(
$request->user()->id . '|' . $request->route('household')
);
// Read from config for easy adjustment
RateLimiter::for('webauthn-authentication', function (Request $request): array {
$config = config('webauthn.rate_limit.authentication_max_attempts');
return [
Limit::perHour($config['per_hour'])->by($request->ip()),
Limit::perMinute($config['per_min'])->by($request->ip()),
];
});

Config file:

config/webauthn.php
return [
'rate_limit' => [
'authentication_max_attempts' => [
'per_hour' => env('WEBAUTHN_AUTH_MAX_PER_HOUR', 40),
'per_min' => env('WEBAUTHN_AUTH_MAX_PER_MIN', 20),
],
'registration_max_attempts' => [
'per_hour' => env('WEBAUTHN_REG_MAX_PER_HOUR', 20),
'per_min' => env('WEBAUTHN_REG_MAX_PER_MIN', 10),
],
],
];

GroupRate LimitCircuit Breaker
webNone (session-based)Yes
api600/minNo
webhooks600/minNo
internal120/minNo

// In controller or service
$limiter = app(RateLimiter::class);
$key = 'otp-verify:' . request()->ip();
$attempts = $limiter->attempts($key);
$remaining = $limiter->remaining($key, 10);
$availableIn = $limiter->availableIn($key);

Rate limits are stored in Redis with keys like:

laravel_cache:throttle:otp-verify:192.168.1.1
laravel_cache:throttle:api:user_123
circuit_breaker:household:123
circuit_breaker:advisor:456
circuit_breaker:organization:789