Validate and throttle API requests
Overview
Interface API Util provides utilities for validating API requests, rate limiting handlers, and returning structured HTTP error responses. The package exports assert for checking conditions, assertValidation for running input validators, and the @RateLimit decorator for throttling handlers. All of them surface failures as HTTPResult errors from @antelopejs/interface-api.
Choose a guard
| Task | Use | Failure behavior |
|---|---|---|
| Require a resource or permission | assert | A falsy condition throws the status and message you supply. |
| Parse an untrusted request body | assertValidation | A synchronous validation error becomes an HTTP error; the default status is 400. |
| Limit repeated calls | RateLimit | Requests above the fixed-window limit receive 429 before the handler runs. |
These guards complement one another. Throttling does not authenticate a caller, and a TypeScript body annotation does not validate incoming JSON.
Getting Started
Install the package in your project:
pnpm add @antelopejs/interface-api-util
Use the utilities in an existing AntelopeJS API controller. The findUser, findPost, removePost, and saveUser functions below stand for your application's persistence functions; they are not exports of this package. The Zod examples also require zod in your project.
Reject a bad request
This endpoint accepts a non-empty name query parameter. With the API provider listening, GET /greet?name=Ada returns a greeting; GET /greet or GET /greet?name= returns HTTP 400. No database or validation library is needed for this example.
import { Controller, Get, Parameter } from "@antelopejs/interface-api";
import { assert } from "@antelopejs/interface-api-util";
class GreetingController extends Controller("/greet") {
@Get("")
greet(@Parameter("name", "query") name: string | true | undefined) {
assert(
typeof name === "string" && name.trim().length > 0,
400,
"Name is required",
);
return { message: `Hello, ${name}!` };
}
}
Use assert for a condition you can state directly, assertValidation for a schema parser, and RateLimit to bound repeated requests. The reference below covers each independently.
assert
The assert function checks whether a condition is truthy. If the condition is falsy, it throws an HTTPResult with the specified HTTP status code and error message.
function assert<T>(
condition: T,
code: number,
message: string,
): asserts condition;
| Parameter | Type | Description |
|---|---|---|
condition | T | The value to check (falsy values trigger the error) |
code | number | HTTP status code for the error response |
message | string | Error message included in the response body |
The function uses TypeScript's asserts keyword, so the compiler narrows the type of condition to truthy after the call.
Check that a resource exists
import { assert } from "@antelopejs/interface-api-util";
import { HTTPResult } from "@antelopejs/interface-api";
async function getUser(id: string) {
const user = await findUser(id);
assert(user, 404, "User not found");
// TypeScript knows `user` is defined here
return new HTTPResult(200, user);
}
Validate permissions
import { assert } from "@antelopejs/interface-api-util";
import { HTTPResult } from "@antelopejs/interface-api";
async function deletePost(postId: string, currentUserId: string) {
const post = await findPost(postId);
assert(post, 404, "Post not found");
assert(
post.authorId === currentUserId,
403,
"Not authorized to delete this post",
);
await removePost(postId);
return new HTTPResult(200, { deleted: true });
}
assertValidation
The assertValidation function runs a synchronous validator on an input value. If the validator throws, assertValidation catches the error and throws an HTTPResult instead. It does not await promises or convert asynchronous rejections; use a synchronous validator such as Zod's parse, not parseAsync.
function assertValidation<T>(
body: unknown,
validator: (body: unknown) => T,
errorFunc?: (err: unknown) => unknown,
code?: number,
): T;
| Parameter | Type | Default | Description |
|---|---|---|---|
body | unknown | The input value to validate | |
validator | (body: unknown) => T | A function that returns the validated value or throws | |
errorFunc | (err: unknown) => unknown | Optional error transformer for customizing the response | |
code | number | 400 | HTTP status code for the error response |
The function returns the validated value on success. On failure, it throws an HTTPResult whose body is either the result of errorFunc or the stringified error.
Validate with Zod
import { assertValidation } from "@antelopejs/interface-api-util";
import * as z from "zod";
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
function createUser(requestBody: unknown) {
const { name, email } = assertValidation(requestBody, createUserSchema.parse);
// `name` and `email` are typed strings here
return saveUser({ name, email });
}
Custom error formatting
Pass an errorFunc to control the error response body.
import { assertValidation } from "@antelopejs/interface-api-util";
import * as z from "zod";
const schema = z.object({ age: z.number().min(18) });
function validateAge(requestBody: unknown) {
return assertValidation(
requestBody,
schema.parse,
(err) => {
if (err instanceof z.ZodError) {
return { errors: err.issues.map((e) => e.message) };
}
return { errors: [String(err)] };
},
422,
);
}
RateLimit
@RateLimit counts requests per key, per decorated handler, in a fixed time window held in process memory. A key's window starts with its first request; it is not aligned to the clock minute. Once the count exceeds limit within windowMs, the guard throws an HTTPResult with status 429 and a Retry-After header before the handler runs.
Allowed requests add X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset to context.response; rejected requests carry them on the thrown response. Reset is a UNIX timestamp in seconds, while Retry-After is a delay in seconds. If a handler replaces the response with a new HTTPResult, header preservation depends on the API provider; retain the context response when you need those headers.
function RateLimit(
limit: number,
windowMs: number,
options?: RateLimitOptions,
): MethodDecorator;
| Parameter | Type | Description |
|---|---|---|
limit | number | Maximum number of requests allowed per window |
windowMs | number | Window duration in milliseconds |
options | RateLimitOptions | Optional custom key function and 429 response body |
interface RateLimitOptions {
key?: (context: RequestContext) => string | null | undefined;
message?: string;
}
By default, requests are counted per client IP (context.rawRequest.socket.remoteAddress). Provide options.key to count by user id, API key, or a forwarded header instead. If the custom key function returns a falsy value (for example a missing route parameter), the limiter falls back to the client IP rather than collapsing every such request into one shared bucket.
Ordering matters. Place
@RateLimitdirectly beneath the route decorator (@Get,@Post, …). The route decorator captures the handler's parameters when it is applied, so a@RateLimitabove it would be ignored — this is detected at startup and throws, rather than silently leaving the route unprotected.
Limit by client IP
import { RateLimit } from "@antelopejs/interface-api-util";
import {
Controller,
Get,
Context,
RequestContext,
} from "@antelopejs/interface-api";
class UsersController extends Controller("/users") {
@Get("")
@RateLimit(100, 60_000)
list(@Context() context: RequestContext) {
context.response.setBody({ users: [] });
return context.response;
}
}
Requests 1–100 from one IP are allowed within its 60-second window; request 101 receives 429. Another decorated handler has a separate counter, even for the same IP.
Limit by a custom key
import { RateLimit } from "@antelopejs/interface-api-util";
import { Controller, Post, HTTPResult } from "@antelopejs/interface-api";
class AuthController extends Controller("auth") {
@Post(":tenant/login")
@RateLimit(5, 60_000, {
key: (ctx) => ctx.routeParameters.tenant,
message: "Too many login attempts, slow down",
})
login() {
return new HTTPResult(200, { ok: true });
}
}
Choose a deployment strategy
| Deployment | What the limiter counts | What to do |
|---|---|---|
| One process, direct clients | Requests per socket IP by default | Use the default key when an IP is the intended identity. |
| Behind a reverse proxy | The proxy's socket IP by default | Supply a key derived from a trusted proxy hop or an authenticated identity. |
| Multiple instances | Independent counters in each process | Enforce a shared limit at the edge or in a shared store. |
Counters reset on process restart. Elapsed windows are pruned periodically, bounding the store by distinct keys seen within an active window rather than retaining keys forever.
Never trust a raw client-supplied X-Forwarded-For as identity: clients can spoof it to evade limits or create many keys. A stable custom key does not make the in-memory counter distributed.