DEV Community

Harpreet Singh Seehra
Harpreet Singh Seehra

Posted on

Build a KV-Backed Rate Limiter with Edge Compute and the Agent SDK

Rate limiting is a foundational API pattern — every production endpoint needs it. But the traditional approach means standing up Redis, wiring it to your API gateway, and building a separate alerting pipeline. That's three services before you've written a single line of business logic.

This sample shows a different approach: a sliding-window rate limiter that runs entirely on Telnyx Edge Compute using the Agent SDK. KV counters track request counts per key, TTL-based windows auto-expire without cleanup code, over-limit requests get rejected with HTTP 429, and SMS alerts fire automatically when rejections cross a threshold — all from a single actor with zero external dependencies.

What We're Building

A rate limiter that:

  • Tracks request counts per key (phone number, IP address, tenant ID) in Edge KV
  • Uses sliding time windows with TTL-based auto-expiry — no cleanup jobs
  • Returns HTTP 429 with metadata when a key exceeds its limit
  • Sends SMS alerts via a zero-credential [telnyx] binding when rejections sustain
  • Isolates each key in its own Agent SDK actor — no contention between keys
  • Provides a /simulate endpoint for testing without real traffic

Architecture

                    Client Request
                           │
                           ▼
              POST /check
                           │
                           ▼
              ┌────────────────────┐
              │   index.ts         │
              │   (HTTP router)     │
              └────┬───────────────┘
                   │
                   ▼
         ┌──────────────────────┐
         │  RateLimitAgent      │  (one actor per key)
         │                      │
         │  1. checkLimit()    │──► KV: GET current window count
         │     if under limit   │
         │  2a. allow()         │──► KV: PUT incremented count (TTL) → 200
         │     if over limit    │
         │  2b. reject()        │──► 429 + track rejection count
         │     if rejections    │
         │     >= threshold     │
         │  3. sendAlert()      │──► SMS via [telnyx] binding
         └──────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The Sliding Window Pattern

The key insight is how KV keys are structured. Each key encodes the rate-limited identifier and the window start time:

rate:+18005551234:1718928000   ← count for the 14:00–14:01 window
rate:+18005551234:1718928060   ← count for the 14:01–14:02 window
rate:+18005559876:1718928000   ← different key, same window
Enter fullscreen mode Exit fullscreen mode

Each KV entry has a TTL of WINDOW_SECONDS (default: 60). When the window passes, the entry auto-expires. No cron jobs, no cleanup code, no Redis eviction policies to tune. This is a fixed-window approximation of a sliding window — simpler than a true sliding window log, but it uses exactly one KV get and one KV put per request.

The Agent SDK Pipeline

Each rate-limited key gets its own RateLimitAgent actor instance. When /check is called, the agent queues a non-blocking pipeline:

Stage 1: checkLimit()

async checkLimit(): Promise<void> {
  const state = await this.getState();
  const windowStart = Math.floor(Date.now() / 1000 / state.windowSeconds) * state.windowSeconds;
  const kvKey = `rate:${state.key}:${windowStart}`;
  const currentStr = await this.env.RATE_KV.get(kvKey);
  const currentCount = currentStr ? parseInt(currentStr, 10) : 0;

  await this.setState({
    ...state,
    currentCount,
    totalRequests: state.totalRequests + 1,
    lastRequestAt: Date.now(),
  });

  if (currentCount < state.limit) {
    await this.queue("allow");
  } else {
    await this.queue("reject");
  }
}
Enter fullscreen mode Exit fullscreen mode

The agent reads the current window count from KV, updates its state, and then queues the next stage based on whether the key is under or over the limit.

Stage 2a: allow()

async allow(): Promise<void> {
  const state = await this.getState();
  const windowStart = Math.floor(Date.now() / 1000 / state.windowSeconds) * state.windowSeconds;
  const kvKey = `rate:${state.key}:${windowStart}`;
  const newCount = state.currentCount + 1;
  await this.env.RATE_KV.put(kvKey, String(newCount), {
    expirationTtl: state.windowSeconds,
  });

  await this.setState({
    ...state,
    currentCount: newCount,
    allowedRequests: state.allowedRequests + 1,
    status: "allowed",
  });
  await this.queue("finalize");
}
Enter fullscreen mode Exit fullscreen mode

The counter is incremented and written back to KV with a TTL. The request is marked as allowed.

Stage 2b: reject()

async reject(): Promise<void> {
  const state = await this.getState();
  const newRejectionCount = state.rejectionCount + 1;

  await this.setState({
    ...state,
    rejectedRequests: state.rejectedRequests + 1,
    rejectionCount: newRejectionCount,
    status: newRejectionCount >= state.alertThreshold ? "alerting" : "rejected",
  });

  if (newRejectionCount >= state.alertThreshold && !state.alertTriggered) {
    await this.queue("sendAlert");
  } else {
    await this.queue("finalize");
  }
}
Enter fullscreen mode Exit fullscreen mode

When a request is rejected, the agent increments its rejection counter. If the rejection count crosses ALERT_THRESHOLD and no alert has been sent yet, the alert stage is queued.

Stage 3: sendAlert()

async sendAlert(): Promise<void> {
  const state = await this.getState();
  const smsText =
    `Rate limit alert: key "${state.key}" has been rejected ${state.rejectionCount} times. ` +
    `Limit is ${state.limit} requests per ${state.windowSeconds}s window. ` +
    `Total requests: ${state.totalRequests}. Allowed: ${state.allowedRequests}. Rejected: ${state.rejectedRequests}.`;

  await this.env.TELNYX.messages.send({
    from: this.env.SENDER_PHONE,
    to: this.env.ALERT_PHONE,
    text: smsText,
  });

  await this.setState({ ...state, alertTriggered: true, status: "done" });
}
Enter fullscreen mode Exit fullscreen mode

The SMS is sent via the [telnyx] binding — no API key in code, no HTTP client, no auth header. The binding handles credentials at the platform level.

Quick Start

1. Clone and install

git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/kv-backed-rate-limiter
npm install
Enter fullscreen mode Exit fullscreen mode

2. Configure environment

Copy .env.example and fill in your values:

cp .env.example .env
Enter fullscreen mode Exit fullscreen mode
Variable Description Example
TELNYX_API_KEY Telnyx API key KEY019...
SENDER_PHONE Telnyx number sending alerts +18005551234
ALERT_PHONE Ops phone receiving alerts +18005559876
RATE_LIMIT Max requests per window per key 10
WINDOW_SECONDS Sliding window duration 60 (1 minute)
ALERT_THRESHOLD Rejections before SMS fires 5

3. Deploy

telnyx-edge secret set TELNYX_API_KEY KEY0123456789ABCDEF
telnyx-edge ship
Enter fullscreen mode Exit fullscreen mode

4. Test the rate limit

# Check a single request
curl -X POST https://your-deployment.telnyxcompute.com/check \
  -H "Content-Type: application/json" \
  -d '{"key":"+18005551234"}'

# Simulate a burst of 15 requests (limit is 10 → 10 allowed, 5 rejected)
curl -X POST https://your-deployment.telnyxcompute.com/simulate \
  -H "Content-Type: application/json" \
  -d '{"key":"+18005551234","count":15}'
Enter fullscreen mode Exit fullscreen mode

The simulate response shows each request, whether it was allowed or rejected, and the current count:

{
  "key": "+18005551234",
  "simulated": 15,
  "allowed": 10,
  "rejected": 5,
  "alertTriggered": true,
  "results": [
    { "request": 1, "action": "allowed", "currentCount": 1 },
    { "request": 2, "action": "allowed", "currentCount": 2 },
    ...
    { "request": 11, "action": "rejected", "currentCount": 10 },
    { "request": 12, "action": "rejected", "currentCount": 10 },
    ...
  ]
}
Enter fullscreen mode Exit fullscreen mode

API Endpoints

Method Path Description
POST /check Check a request against the rate limit
GET /status/:key Get agent state for a specific key
GET /count/:key Get current window count for a key
GET /keys List all tracked keys with aggregate stats
POST /reset/:key Reset a key's counter
POST /simulate Simulate a burst of requests for testing
GET /config Get current rate limit configuration
GET /health/liveness Liveness probe
GET /health/readiness Readiness probe

Why This Matters

No Redis, no external services

The traditional rate limiter needs Redis for the counter store and a separate alerting service (SNS, PagerDuty, etc.) for threshold notifications. This sample replaces both with Edge KV (built-in binding) and the [telnyx] messaging binding (zero-credential SMS). One deploy, zero external dependencies.

TTL-based cleanup

The sliding window is implemented as a fixed-window approximation: each window gets its own KV key with a TTL. When the window expires, the key auto-expires. No cleanup cron jobs, no eviction policy tuning, no memory management. The window boundary is computed from Math.floor(Date.now() / 1000 / windowSeconds) * windowSeconds.

Per-key actor isolation

Each rate-limited key gets its own RateLimitAgent actor instance. This means:

  • No contention between concurrent keys
  • Per-key state (allowed/rejected counts, alert status) survives across requests
  • The RateLimitRegistry singleton actor aggregates across keys for the /keys endpoint

Non-blocking pipeline

The this.queue() pattern from the Agent SDK means each stage runs asynchronously. The HTTP request that triggers checkRequest() returns immediately — the pipeline stages execute in the background. This is critical for rate limiting: you don't want the client to wait for the alert SMS to send before getting their 429.

Use Cases

  • API rate limiting — Limit requests per API key, IP, or tenant
  • Call Control protection — Limit inbound call rate per phone number before they hit your IVR
  • SMS flood prevention — Rate limit outbound SMS per user before sending
  • Webhook throttling — Limit incoming webhook rate per source to protect downstream services
  • Tenant quotas — Per-tenant request caps on multi-tenant platforms

Frequently Asked Questions

How is this different from a Redis-based rate limiter?

Redis requires a separate service to provision, manage, and pay for. Edge KV is a built-in binding — no provisioning, no connection pooling, no network hops. The TTL-based cleanup is native to KV. The trade-off: Edge KV is eventually consistent, so there's a small window where concurrent requests might both read the same count. For most rate limiting use cases, this is acceptable.

Can I use a true sliding window instead of a fixed window?

Yes. Replace the single counter per window with a sorted set of timestamps (one per request) and count entries within the window. This is more accurate but uses more KV storage and requires listing keys within a range. The fixed-window approximation is simpler and uses exactly one KV get and one KV put per request.

What happens if the SMS alert fails?

The agent catches the error and sets its status to "error" with the error message. The alert is not retried automatically, but the alertTriggered flag is only set on success, so the next rejection will queue another alert attempt. You can monitor for the "error" status via the /status/:key endpoint.

How do I reset a key's counter?

Use the POST /reset/:key endpoint. This deletes the current window's KV entry for that key, effectively resetting the counter to zero.

Resources

Top comments (0)