rl-nginx

What RateLimitly and rl-nginx do

RateLimitly is a distributed admission-control service. It decides whether an application may begin work that consumes configured resources. The decision may also depend on whether recently observed service latencies remain below application-defined thresholds.

rl-nginx is the nginx HTTP integration for that decision. After nginx access control and pre-content routing have succeeded, the module turns configured nginx values into one RateLimitly resource request containing resource consumptions, latency guards, or both. A valid grant consumes every requested resource, if any, and nginx proceeds directly to content processing. A rejection consumes nothing and returns 429 Too Many Requests.

The module uses the public rl-c-client for credentials, DNS membership, packet encoding, request delivery, response selection, and canonical state identifiers. This repository documents what the nginx module adds: directives, nginx phase ordering, failure policy, request lifetime, latency measurement, and operations.

The operation model

RateLimitly exposes two independent logical operations:

A location opts into the first operation with ratelimitly and independently opts into the second with ratelimitly_report. A guard never enables reporting implicitly, and reporting does not require a guard or even a resource request. When requested, nginx sends one latency sample after serving the main request; it never delays or changes the HTTP result to deliver that sample.

flowchart LR
    HTTP["HTTP request"] --> Checks["nginx access control<br/>and pre-content routing"]
    Checks --> Request["Optional resource request<br/>resources, guards, or both"]
    Request --> Decision{"RateLimitly decision"}
    Decision -->|Rejected| Deny["429<br/>nothing consumed"]
    Decision -->|Failure| Policy["Configured<br/>fail-open / fail-close"]
    Policy -->|open| Content
    Policy -->|close| Deny
    Decision -->|Granted| Content["Requested resources consumed, if any<br/>serve or proxy content"]
    Checks -->|no admission rule| Content
    Content -. "when ratelimitly_report is configured" .-> Report["One best-effort latency report"]
    Report --> Trackers["Latency trackers"]
    Trackers -. "evaluated by future guards" .-> Decision

The version-matched C-client documentation is authoritative for the operation model and the distinction between resource requests and latency reports.

Three small nginx examples

These snippets assume that the tenant, API key, resolver, and upstream are configured as shown in Minimal Configuration.

Consume one resource

In English: “Before serving /checkout/, get me one token from the checkout bucket, whose limit is 100 tokens per second.”

ratelimitly_zone checkout "bucket=checkout" rate=100r/s;

location /checkout/ {
  ratelimitly zone=checkout;
  proxy_pass http://127.0.0.1:9000;
}

A grant consumes one token and authorizes nginx to proxy the request. A rejection consumes nothing and nginx returns 429.

Consume two resources atomically

In English: “Get me one global checkout token and one token for this client address; proceed only if both are available.”

ratelimitly_zone checkout_global "bucket=checkout|scope=global" rate=100r/s;
ratelimitly_zone checkout_client
  "bucket=checkout|scope=client|ip=$remote_addr"
  rate=5r/s;
ratelimitly_group checkout_limits zone=checkout_global zone=checkout_client;

location /checkout/ {
  ratelimitly group=checkout_limits;
  proxy_pass http://127.0.0.1:9000;
}

RateLimitly evaluates the group as one request. A grant consumes one token from both buckets; if either limit rejects the request, neither consumption occurs. Before using an address as identity, configure nginx real-IP or proxy-protocol trust correctly.

Add one latency guard

In English: “Get me one checkout token, but only while the tracked inventory latency is below 100 ms. Also report the latency measured by nginx after the request is served.”

ratelimitly_zone checkout "bucket=checkout" rate=100r/s;

ratelimitly_tracker inventory_tracker
  "service=inventory"
  ttl=30s;
ratelimitly_guard inventory_latency
  tracker=inventory_tracker
  threshold=100ms;

location /checkout/ {
  ratelimitly zone=checkout guard=inventory_latency;
  ratelimitly_report inventory_tracker;
  proxy_pass http://127.0.0.1:9000;
}

The resource consumption and guard are one atomic admission request. The separate report directive measures from nginx request start to log phase and sends one sample after completed work. A guard without that directive does not report. A report can likewise be configured on a location that has no guard.

A guard can also be the complete admission policy when the operation does not consume a rate-limited resource:

location /inventory-health/ {
  ratelimitly guard=inventory_latency;
  proxy_pass http://127.0.0.1:9000;
}

This sends a Rate Request with no resource consumptions and one latency guard. A passing guard admits the HTTP request; a failing guard returns 429. It does not send a latency report unless the location also declares ratelimitly_report inventory_tracker;.

Allow, deny, and failure are different outcomes

Every protected request must distinguish three outcomes:

Outcome Meaning nginx behavior
Grant RateLimitly approved the complete request and consumed every requested resource. Continue to content processing.
Rejection RateLimitly rejected at least one condition and consumed nothing. Return 429 Too Many Requests.
Failure No usable RateLimitly decision was obtained. It is neither a grant nor a rejection. Apply ratelimitly_fail open\|close.

A failure after transmission does not prove that no server processed the request. Choose the failure policy as an explicit availability-versus-control decision; see Request and failure policy.

The planned 0.1.x public preview is source-only. It supports static and dynamic module builds on Linux with glibc and nginx 1.30.2 or 1.31.1; the exact release scope is in the compatibility guide.

Quick Start

The commands below use the repository’s pinned nginx 1.31.1 submodule and automatically fetch the locked public rl-c-client v1.0.0 release. No private repository, RateLimitly server, tenant, or API key is needed to build and run the public test suite.

On Debian or Ubuntu, install the required tools and build dependencies:

sudo apt-get update
sudo apt-get install -y \
  build-essential curl dnsutils git libpcre2-dev libssl-dev procps python3 \
  zlib1g-dev

Clone the repository with its pinned nginx source and run the required static contributor gate:

git clone --recurse-submodules https://github.com/ratelimitly-com/rl-nginx.git
cd rl-nginx
make check

make check verifies scripts and dependency locks, builds the static module, checks nginx configuration, runs the deterministic public integration suite, and checks whitespace. The integration suite reuses the exact static binary built from BUILD_FLAGS; dynamic flags are rejected because that mode does not produce the nginx binary this target exercises. It materializes the C client at ./_deps/rl-c-client; that checkout must match the tag and full commit in dependencies/rl-c-client.env and have a clean working tree.

The resulting static nginx binary is:

upstream-nginx/objs/nginx

For a dynamic module instead, run:

make build BUILD_FLAGS="--dynamic --compat --clean"
make dynamic-relocation-test

The dynamic artifact is:

upstream-nginx/objs/ngx_http_rn_module.so

Build a deployment artifact against the same nginx release and compatible configure options as the nginx binary that will run it. See Building rl-nginx before installing either artifact.

Minimal Configuration

The tenant domain and API key below are deliberately non-working placeholders. Replace both before running nginx -t. Also replace the resolver address if 127.0.0.53 is not the DNS resolver available to your nginx workers. The resolver must be declared directly in the http context: the module has one worker-local client, so server/location resolver overrides do not select its discovery resolver.

events {}

http {
  resolver 127.0.0.53 valid=30s ipv6=off;

  ratelimitly_dns_srv  tenant.example.invalid;
  ratelimitly_auth_key rl-aes1REPLACE_WITH_YOUR_KEY;
  ratelimitly_policy   standard unit=50ms;
  ratelimitly_fail     close;

  ratelimitly_zone api
    "bucket=v1|scope=api|ip=$remote_addr"
    rate=100r/s;

  server {
    listen 8080;

    location /api/ {
      ratelimitly_label "scope=api";
      ratelimitly zone=api;
      proxy_pass http://127.0.0.1:9000;
    }
  }
}

For a dynamic build, load the installed module before the events block:

load_module modules/ngx_http_rn_module.so;

RateLimitly discovery queries _ratelimitly._udp.<your-tenant-domain>. The control plane must provide the tenant, API key, and corresponding DNS SRV record; this repository does not create or run those services. Start from examples/minimal.conf, review examples/security-conscious.conf, and read the configuration guide before deploying. Treat ratelimitly_auth_key as a secret.

nginx Integration Contract

rl-nginx owns the HTTP-specific part of the workflow:

rl-c-client owns the client mechanics beneath this contract. Its Resource-Request HA Policy defines fan-out, oldest-server preference, replay, completion delivery, and deduplication TTL. Its DNS Refresh and Error Codes sections define the client behavior that the module adapts to nginx.

This module does not create tenants, issue credentials, manage RateLimitly DNS, or include a RateLimitly server.

Core Directives

Rendered bucket and tracker service keys are limited to 1024 bytes; labels are limited to 256 bytes. Quote a complete named argument ("bucket=value" or "service=value"), not only its value. Empty/oversized dynamic identifiers follow ratelimitly_fail, while invalid static values fail nginx -t.

See the configuration guide for directive behavior and the DSL reference for complete syntax.

$ratelimitly_verdict is allow or deny only for a valid RateLimitly decision. It is not found (normally logged as -) for fail-open, fail-close, bypassed, unfinished, internal-error, and aborted requests. It reports decision provenance, not the final upstream/content status.

Supported Dependency Policy

Supported builds use immutable inputs:

Dependency Supported revision
nginx stable release-1.30.2 at a92a537860c7b87d3793d9eb41c9cf3ed833b53c
nginx mainline and default submodule release-1.31.1 at d44205284fa41662da803b796d6056fc1e59b1f3
rl-c-client v1.0.0 at 22fc045717ef01e37ab483e9a48e539845ae8124

Set NGINX_SRC=/path/to/nginx-src when testing another supported nginx source tree. Set RCLIENT_DIR=/path/to/rl-c-client only when intentionally developing or packaging against another client checkout. An override is not a supported release lock, may contain local changes, and no sibling checkout is selected implicitly. The selected client must also pass the callback and ownership contract.

Scheduled compatibility probes test rl-c-client/main and nginx master for early warning only. Required builds and support claims remain on the immutable revisions above.

Testing

The required static contributor entrypoint is:

make check

Release readiness is intentionally broader. For both supported nginx releases, required CI runs the static contributor gate and relocated dynamic behavior on native x86_64 and aarch64, plus ASan/UBSan/LSan on x86_64. The optional private full-stack test is supplemental evidence, not a public acceptance gate. See the compatibility evidence contract for the exact boundary.

Useful narrower gates are:

make syntax
make unit
make build
make config-test
make public-test
make dynamic-relocation-test
make sanitizers

The public integration suite uses the locked C-client responder and local DNS fixture. It covers enforcement boundaries, fail-open/fail-closed outages, DNS failure and recovery, timeouts, aborted clients, bounded invalid-UDP ingress, steering rebinds, guards, malformed responses, response cardinality, reload, worker survival, and clean shutdown. It requires no real tenant or credential.

The unit gate also runs negative oracle fixtures. They deliberately break each required Make/CI/specification control and require its checker to turn red; runtime oracle tests likewise reject a wrong fail-close status, a forced nginx shutdown, and an incomplete public lifecycle manifest.

tests/smoke-test.sh and tests/burst-test.sh are manual diagnostics, not release gates. The private full-stack harness is optional and is not required for public contributors. See the integration-test guide for those workflows.

Do not report suspected vulnerabilities in a public issue. Follow SECURITY.md instead, and remove tenant credentials from every configuration snippet and log you share.

Repository Layout

rl-nginx/
  src/                         nginx module source
  examples/                    copyable nginx configurations
  docs/                        build, configuration, and operations guides
  integration-tests/           deterministic public test harness
  spec/                        detailed configuration and behavior references
  tests/                       lower-level tests and manual diagnostics
  tools/build-nginx.sh         supported build helper
  config                       nginx module build descriptor

This repository is licensed under the MIT License; see LICENSE.