Skip to content

Commit 2cf1f51

Browse files
committed
fix: adding rate-limit
1 parent 6a4bf11 commit 2cf1f51

5 files changed

Lines changed: 81 additions & 5 deletions

File tree

app/Providers/AppServiceProvider.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
use App\Logic\LicenceVerification;
66
use App\Logic\NetifydCatalogRepository;
77
use App\Logic\NetifydLicenseRepository;
8+
use Illuminate\Cache\RateLimiting\Limit;
89
use Illuminate\Foundation\Events\DiagnosingHealth;
10+
use Illuminate\Http\Request;
911
use Illuminate\Support\Facades\Event;
12+
use Illuminate\Support\Facades\RateLimiter;
1013
use Illuminate\Support\ServiceProvider;
1114
use Laravel\Nightwatch\Facades\Nightwatch;
1215

@@ -36,5 +39,9 @@ public function boot(): void
3639
Event::listen(function (DiagnosingHealth $event) {
3740
Nightwatch::dontSample();
3841
});
42+
43+
RateLimiter::for('netifyd', function (Request $request) {
44+
return Limit::perMinute(config('netifyd.rate-limit'));
45+
});
3946
}
4047
}

bootstrap/app.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
use Illuminate\Foundation\Application;
44
use Illuminate\Foundation\Configuration\Exceptions;
55
use Illuminate\Foundation\Configuration\Middleware;
6+
use Illuminate\Http\Exceptions\ThrottleRequestsException;
7+
use Illuminate\Http\Request;
8+
use Symfony\Component\HttpFoundation\Response;
69

710
return Application::configure(basePath: dirname(__DIR__))
811
->withRouting(
@@ -17,5 +20,17 @@
1720
]);
1821
})
1922
->withExceptions(function (Exceptions $exceptions) {
20-
//
23+
$exceptions->render(function (ThrottleRequestsException $e, Request $request) {
24+
$headers = $e->getHeaders();
25+
26+
if (array_key_exists('Retry-After', $headers)) {
27+
$headers['Retry-After'] = random_int(300, 900);
28+
}
29+
30+
return response()->json(
31+
['message' => 'Too many requests, please retry later.'],
32+
Response::HTTP_SERVICE_UNAVAILABLE,
33+
$headers
34+
);
35+
});
2136
})->create();

config/netifyd.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@
88
'endpoint' => env('NETIFYD_API_ENDPOINT', 'https://agents.netify.ai'),
99
'api-key' => env('NETIFYD_API_KEY'),
1010
'license-suffix' => env('NETIFYD_LICENSE_SUFFIX'),
11+
'rate-limit' => env('NETIFYD_RATE_LIMIT', 180),
1112
];

routes/api.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010
Route::prefix('/netifyd')->group(function () {
1111
Route::get('/license', [NetifyLicenseController::class, 'community']);
1212

13-
Route::get('/applications/catalog', [NetifydCatalogController::class, 'applicationsCatalog']);
14-
Route::get('/applications/categories', [NetifydCatalogController::class, 'applicationsCategories']);
15-
Route::get('/protocols/catalog', [NetifydCatalogController::class, 'protocolsCatalog']);
16-
Route::get('/protocols/categories', [NetifydCatalogController::class, 'protocolsCategories']);
13+
Route::middleware('throttle:netifyd')->group(function () {
14+
Route::get('/applications/catalog', [NetifydCatalogController::class, 'applicationsCatalog']);
15+
Route::get('/applications/categories', [NetifydCatalogController::class, 'applicationsCategories']);
16+
Route::get('/protocols/catalog', [NetifydCatalogController::class, 'protocolsCatalog']);
17+
Route::get('/protocols/categories', [NetifydCatalogController::class, 'protocolsCategories']);
18+
});
1719

1820
Route::middleware(ForceBasicAuth::class)->group(function () {
1921
Route::get('/enterprise/license', [NetifyLicenseController::class, 'enterprise'])
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
use App\Logic\NetifydCatalogRepository;
4+
use Illuminate\Support\Facades\Config;
5+
use Mockery\MockInterface;
6+
7+
use function Pest\Laravel\get;
8+
use function Pest\Laravel\partialMock;
9+
10+
it('returns 503 with a Retry-After header once the netifyd rate limit is exceeded', function () {
11+
Config::set('netifyd.rate-limit', 2);
12+
13+
partialMock(NetifydCatalogRepository::class, function (MockInterface $mock) {
14+
$mock->allows('applicationsCatalog')->andReturn([]);
15+
});
16+
17+
get('/api/netifyd/applications/catalog')->assertOk();
18+
get('/api/netifyd/applications/catalog')->assertOk();
19+
20+
get('/api/netifyd/applications/catalog')
21+
->assertServiceUnavailable()
22+
->assertHeader('Retry-After');
23+
});
24+
25+
it('returns a randomized Retry-After header between 300 and 900 seconds', function () {
26+
Config::set('netifyd.rate-limit', 1);
27+
28+
partialMock(NetifydCatalogRepository::class, function (MockInterface $mock) {
29+
$mock->allows('applicationsCatalog')->andReturn([]);
30+
});
31+
32+
get('/api/netifyd/applications/catalog')->assertOk();
33+
34+
$response = get('/api/netifyd/applications/catalog')->assertServiceUnavailable();
35+
36+
expect((int) $response->headers->get('Retry-After'))
37+
->toBeGreaterThanOrEqual(300)
38+
->toBeLessThanOrEqual(900);
39+
});
40+
41+
it('rate limits globally across clients, not per ip', function () {
42+
Config::set('netifyd.rate-limit', 1);
43+
44+
partialMock(NetifydCatalogRepository::class, function (MockInterface $mock) {
45+
$mock->allows('applicationsCatalog')->andReturn([]);
46+
});
47+
48+
get('/api/netifyd/applications/catalog', ['X-Forwarded-For' => '203.0.113.10'])->assertOk();
49+
get('/api/netifyd/applications/catalog', ['X-Forwarded-For' => '198.51.100.20'])
50+
->assertServiceUnavailable();
51+
});

0 commit comments

Comments
 (0)