sla-wizard-plugin-auth-request-ratelimit
sla-wizard-plugin-auth-request-ratelimit
A sla-wizard plugin that augments nginx's static limit_req rate limiting with a dynamic auth_request-based delegation to alma-telemeter.
When alma-telemeter is reachable, every request passes through a subrequest that enforces the rate limit and returns live X-RateLimit-* headers. When alma-telemeter is unreachable, nginx falls back to its own limit_req token-bucket limiter so SLA limits are always enforced.
How it fits in the plugin chain
configNginxAuthRequest
└─ sla-wizard-plugin-custom-baseurl (configNginxBaseUrl)
└─ sla-wizard-plugin-nginx-strip (configNginxStrip)
└─ sla-wizard-nginx-confd (configNginxConfd)
Architecture: dual-layer rate limiting
The plugin deliberately keeps both limiters active at all times:
Client request
│
├─ [nginx preaccess] limit_req zone=… burst=N nodelay
│ token-bucket guard; burst = SLA_max − 1
│ → 429 @rate_limited_static (if burst exhausted)
│
├─ [nginx rewrite] set $rl_limit_static N+1
│
└─ [nginx access] auth_request /internal/rate-limit-check
│
├─ Telemeter UP → 200 (allowed) or 403 (exceeded)
│ 200 → forward upstream + dynamic X-RateLimit-* headers
│ 403 → error_page 403 =429 @rate_limited (dynamic headers)
│
└─ Telemeter DOWN (5xx / timeout)
→ /_telemeter_down returns 200
→ request allowed through; limit_req already ran above
→ static X-RateLimit-* headers from map fallback
Path summary
| Scenario | Enforcer | Rate-limit headers |
|---|---|---|
| Telemeter UP, within limit | telemeter (fixed-window Redis) | Dynamic from telemeter |
| Telemeter UP, limit exceeded | telemeter → @rate_limited |
Dynamic from telemeter |
| Telemeter DOWN, within burst | limit_req (token-bucket) |
Static ($rl_limit_static) |
| Telemeter DOWN, burst exceeded | limit_req → @rate_limited_static |
Static, partial |
| Redis DOWN | telemeter fail-open → limit_req |
Static ($rl_limit_static) |
Installation
npm install sla-wizard-plugin-auth-request-ratelimit
Runtime dependency
alma-telemeter must be reachable from nginx. It receives subrequests at /internal/rate-limit and responds with X-RateLimit-* headers.
Default endpoint: http://127.0.0.1:2047/internal/rate-limit. Override with --telemeterUrl CLI option or TELEMETER_URL environment variable.
CLI usage
// cli.js
const slaWizard = require('sla-wizard');
const authRequestPlugin = require('sla-wizard-plugin-auth-request-ratelimit');
slaWizard.use(authRequestPlugin);
slaWizard.program.parse(process.argv);
node cli.js --help
config-nginx-auth-request
Generates a full nginx configuration (nginx.conf + conf.d/) with auth_request-based rate limiting applied.
node cli.js config-nginx-auth-request \
-o <outputDirectory> \
--oas <pathToOAS> \ # default: ./specs/oas.yaml
--sla <slaFileOrDirectory> \ # default: ./specs/sla.yaml
--authLocation <header|query|url> \ # default: header
--authName <paramName> \ # default: apikey
--proxyPort <port> \ # default: 80
--telemeterUrl <url> \ # default: http://127.0.0.1:2047/internal/rate-limit
[--customTemplate <file>]
add-to-auth-request-confd
Generates only conf.d/ files for a new user without touching nginx.conf. Use when nginx.conf was already generated by a previous config-nginx-auth-request run.
node cli.js add-to-auth-request-confd \
-o <outputDirectory> \
--oas <pathToOAS> \
--sla <slaFileOrDirectory>
Programmatic usage
const slaWizard = require('sla-wizard');
const authRequestPlugin = require('sla-wizard-plugin-auth-request-ratelimit');
slaWizard.use(authRequestPlugin);
// Full config (nginx.conf + conf.d/)
slaWizard.configNginxAuthRequest({
outDir: './nginx-output',
oas: './specs/oas.yaml',
sla: './specs/slas',
telemeterUrl: 'http://telemeter-host:2047/internal/rate-limit', // optional
});
// conf.d only (nginx.conf already exists)
slaWizard.addToAuthRequestConfd({
outDir: './nginx-output',
oas: './specs/oas.yaml',
sla: './specs/slas/new-user.yaml',
});
Direct import
const {
DEFAULT_TELEMETER_URL,
applyAuthRequestToConfd, // (content: string) → string
applyAuthRequestToNginxConf, // (content: string, telemeterUrl?: string) → string
applyAuthRequestTransformations, // (outDir: string, telemeterUrl?: string) → void
applyAuthRequestTransformationsConfdOnly, // (outDir: string) → void
} = require('sla-wizard-plugin-auth-request-ratelimit');
const transformed = applyAuthRequestToConfd(fs.readFileSync('user.conf', 'utf8'));
applyAuthRequestTransformations('./nginx-output', 'http://telemeter-host:2047/internal/rate-limit');
nginx transformations applied
nginx.conf changes
| Element | After transformation |
|---|---|
limit_req_zone … |
(kept — nginx-native fallback when telemeter is down) |
limit_req_status 429; |
(kept — fallback) |
Before server { |
Two map directives injected (header fallback logic) |
Before include conf.d/*.conf; |
location /internal/rate-limit-check injected |
Before include conf.d/*.conf; |
location = /_telemeter_down injected |
Before include conf.d/*.conf; |
location @rate_limited injected |
Before include conf.d/*.conf; |
location @rate_limited_static injected |
Injected map directives (http context, before server {):
map $rl_limit $rl_limit_header {
"" $rl_limit_static; # telemeter down or Redis fail-open → static value
default $rl_limit; # telemeter up → dynamic value
}
map $rl_remaining $rl_remaining_header {
"" -1; # telemeter down → -1 signals unavailable
default $rl_remaining; # telemeter up → dynamic value
}
Injected /internal/rate-limit-check block:
location /internal/rate-limit-check {
internal;
proxy_pass http://127.0.0.1:2047/internal/rate-limit;
proxy_pass_request_body off;
proxy_set_header X-API-Key $http_apikey;
proxy_set_header X-Endpoint $uri_original;
proxy_set_header X-Method $request_method;
proxy_connect_timeout 500ms;
proxy_read_timeout 1s;
proxy_intercept_errors on;
error_page 500 502 503 504 =200 /_telemeter_down;
}
Injected /_telemeter_down block (telemeter unreachable → allow through, fall back to limit_req):
location = /_telemeter_down {
internal;
return 200;
}
Injected @rate_limited block (telemeter rejected the request → dynamic 429):
location @rate_limited {
default_type application/json;
add_header X-RateLimit-Limit $rl_limit always;
add_header X-RateLimit-Remaining 0 always;
add_header X-RateLimit-Reset $rl_reset always;
add_header Retry-After $rl_retry always;
return 429 '{"error":"TooManyRequests","message":"Rate limit exceeded","status":429}';
}
Injected @rate_limited_static block (limit_req rejected the request → static 429):
location @rate_limited_static {
default_type application/json;
add_header X-RateLimit-Remaining 0 always;
add_header Retry-After 60 always;
return 429 '{"error":"TooManyRequests","message":"Rate limit exceeded","status":429}';
}
conf.d/*.conf changes (per rate-limited location block)
The original limit_req line is kept as the first directive (nginx-native fallback). The auth_request block is injected immediately after it.
Before:
location /sla-user1_endpoint_POST {
rewrite … break;
proxy_pass http://localhost:8000;
limit_req zone=sla-user1_endpoint_POST burst=4 nodelay;
}
After:
location /sla-user1_endpoint_POST {
rewrite … break;
proxy_pass http://localhost:8000;
limit_req zone=sla-user1_endpoint_POST burst=4 nodelay; # kept as fallback
set $rl_limit_static 5; # burst+1 = SLA max; static header fallback
auth_request /internal/rate-limit-check;
auth_request_set $rl_limit $upstream_http_x_ratelimit_limit;
auth_request_set $rl_remaining $upstream_http_x_ratelimit_remaining;
auth_request_set $rl_reset $upstream_http_x_ratelimit_reset;
auth_request_set $rl_retry $upstream_http_retry_after;
add_header X-RateLimit-Limit $rl_limit_header always;
add_header X-RateLimit-Remaining $rl_remaining_header always;
add_header X-RateLimit-Reset $rl_reset always;
error_page 403 =429 @rate_limited;
error_page 429 @rate_limited_static;
}
$rl_limit_header and $rl_remaining_header resolve via the map directives in nginx.conf:
- Telemeter UP: take dynamic values from telemeter response headers.
- Telemeter DOWN:
$rl_limit_header→$rl_limit_static;$rl_remaining_header→-1.
Response headers
On successful (2xx) responses
| Telemeter state | Header | Value |
|---|---|---|
| UP | X-RateLimit-Limit |
Dynamic — SLA max from telemeter |
| UP | X-RateLimit-Remaining |
Dynamic — requests left in current window |
| UP | X-RateLimit-Reset |
Dynamic — Unix timestamp of window reset |
| DOWN | X-RateLimit-Limit |
Static — $rl_limit_static (SLA burst + 1) |
| DOWN | X-RateLimit-Remaining |
-1 (sentinel: telemeter unavailable) |
| DOWN | X-RateLimit-Reset |
(absent — limit_req does not expose window time) |
On rate-limited (429) responses
Via @rate_limited (telemeter rejected the request):
| Header | Value |
|---|---|
X-RateLimit-Limit |
Dynamic — SLA max |
X-RateLimit-Remaining |
0 |
X-RateLimit-Reset |
Dynamic — Unix timestamp of window reset |
Retry-After |
Dynamic — seconds until window resets |
Via @rate_limited_static (limit_req burst exhausted before auth_request ran):
| Header | Value |
|---|---|
X-RateLimit-Remaining |
0 |
Retry-After |
60 (conservative hardcoded fallback) |
X-RateLimit-Limit |
(absent — set $rl_limit_static runs in rewrite phase, after limit_req preaccess rejection) |
X-RateLimit-Reset |
(absent — limit_req does not expose window time) |
Response body (both 429 paths):
{"error":"TooManyRequests","message":"Rate limit exceeded","status":429}
Headers not added
Responses rejected before any location block runs — 401 Unauthorized (missing API key) and 403 Forbidden (unknown API key) — do not carry X-RateLimit-* headers because the auth_request subrequest never fires for those requests.
Exported API reference
| Export | Signature | Description |
|---|---|---|
apply |
(program, ctx) → void |
sla-wizard plugin entry point |
DEFAULT_TELEMETER_URL |
string |
http://127.0.0.1:2047/internal/rate-limit |
configNginxAuthRequest |
(options, ctx) → void |
Full config generation (nginx.conf + conf.d/) |
addToAuthRequestConfd |
(options, ctx) → void |
conf.d-only generation |
applyAuthRequestToConfd |
(content: string) → string |
Transforms one conf.d file content |
applyAuthRequestToNginxConf |
(content: string, telemeterUrl?: string) → string |
Transforms nginx.conf content |
applyAuthRequestTransformations |
(outDir: string, telemeterUrl?: string) → void |
Transforms all files in outDir |
applyAuthRequestTransformationsConfdOnly |
(outDir: string) → void |
Transforms only conf.d/ files |
Testing
# Unit tests + container integration tests (requires Docker)
npm test
# Unit tests only (no Docker required)
npx mocha ./tests/tests.js
Test suite overview
| Suite | Tests | Type |
|---|---|---|
applyAuthRequestToConfd |
17 | Unit (pure string) |
applyAuthRequestToNginxConf |
17 | Unit (pure string) |
applyAuthRequestTransformations |
6 | Unit (filesystem) |
applyAuthRequestTransformationsConfdOnly |
4 | Unit (filesystem) |
| Module shape + plugin registration | 9 | Unit |
configNginxAuthRequest programmatic |
23 | Integration |
addToAuthRequestConfd programmatic |
6 | Integration |
| CLI usage | 10 | CLI (execSync) |
| Container: X-RateLimit headers under limit | 8 | Container (Docker) |
| Container: 429 with correct headers | 6 | Container (Docker) |
| Container: independent counters per key | 3 | Container (Docker) |
| Container: authentication checks | 2 | Container (Docker) |
| Total | 111 |
The container tests spin up three Docker containers (echo backend, mock alma-telemeter, nginx with the generated config) and verify the full request cycle end-to-end, including live header values and rate-limit enforcement.