sla-wizard-plugin-auth-request-ratelimit
v1.0.2
Published
Plugin that replaces nginx limit_req with auth_request-based rate limiting, enabling dynamic X-RateLimit headers via alma-telemeter
Downloads
422
Readme
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 fallbackPath 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-ratelimitRuntime 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 --helpconfig-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.jsTest 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.
