Openship Rate-Limiter Middleware: Complete Guide to API Endpoint Protection
Openship protects its public API with a centralized rate-limiting layer that assigns named policies to requests based on IP, user, or organization identifiers, returning HTTP 429 with standard Retry-After headers when limits are exceeded.
The Openship rate-limiter middleware provides enterprise-grade API endpoint protection for the open-source logistics platform. Implemented in TypeScript within the apps/api package, this system sits in the Hono request-handling stack to prevent brute-force attacks and resource exhaustion. Whether you are operating a SaaS deployment or extending the platform, understanding this architecture allows you to configure appropriate throttling boundaries.
How the Rate-Limiter Middleware Works
The implementation lives in apps/api/src/middleware/rate-limiter.ts and operates through two distinct entry points that enforce policies across the routing tree.
Two Entry Points for Policy Enforcement
rateLimiterFor(policyId) returns a per-route Hono middleware bound to a specific named policy. Use this for isolated endpoints that bypass the secure router.
globalAnonLimiter (exported as rateLimiter) mounts on the entire /api tree. It enforces either the default-anon or default-authed policy when no route-specific override exists.
When a route specifies a custom limit through secureRouter, the system sets c.set("rateLimitPolicy", …) upstream. The global limiter detects this via c.get("rateLimitApplied") and skips its own default enforcement to avoid double-counting.
Subject Resolution Strategy
The helper resolveSubjectId extracts the identifier used for bucket tracking based on the policy configuration:
"ip"– Client IP fromc.var.clientIp(or loopback in development)."user"/"org"– Pulled fromgetRequestContextfor authenticated requests."global"– Static string"global"for cluster-wide caps.
Health Endpoint Exemptions and Dev Bypass
Requests to /api/health/* are never rate-limited (see lines 34–37 in rate-limiter.ts), ensuring dashboard health checks cannot trigger cascade failures.
During local development, when env.TRUST_PROXY is false and the connection originates from the loopback interface, requests bypass ordinary limits (except the auth gate). This prevents developer workstations from exhausting per-IP buckets during testing.
Rate-Limit Policy Catalog
Policies are defined in apps/api/src/lib/rate-limit/policies.ts as named buckets with specific limits, windows, and subject types. Extend the PolicyId union and update the POLICIES map to add custom rules.
| Policy | Limit (req/min) | Subject | Use Case |
|---|---|---|---|
default-anon |
300 | IP | Catch-all for public routes |
default-authed |
3000 | User | Default for authenticated routes |
auth-tight |
10 | IP | Login/signup brute-force protection |
auth-loose |
60 | User | Post-authentication actions |
mcp |
300 | IP | JSON-RPC tooling endpoints |
read-authed |
600 | User | Read-only API calls |
write-authed |
300 | User | Write operations (POST/PUT/PATCH/DELETE) |
webhook-ingress |
120 | IP | Inbound webhook deliveries |
billing-portal |
20 | Org | Stripe portal creation |
Backend Storage: Redis vs In-Memory
The pickBackend function in apps/api/src/lib/rate-limit/index.ts (lines 35–44) selects the storage implementation at first use. Set OPENSHIP_RATE_LIMIT_STORE=memory|redis to override the automatic selection.
- Redis store (
redis-store.ts): Recommended for production SaaS deployments. Uses atomic Redis commands for distributed counter consistency. - Memory store (
memory-store.ts): Fallback for single-instance installations or local development. Maintains counters in-process.
Implementing API Endpoint Protection
Protecting Routes with secureRouter
When using the secure router abstraction, declare rate limits in the route specification:
secureRouter.post(
"/auth/sign-in",
{ public: true, rateLimit: "auth-tight" },
authController.signIn,
);
Attaching Middleware Directly
For endpoints that bypass secureRouter, import rateLimiterFor and apply it directly:
import { rateLimiterFor } from "../../middleware/rate-limiter";
router.patch(
"/servers/:id/rate-limit",
rateLimiterFor("auth-tight"),
serverRateLimitController.update,
);
Manual Rate-Limit Checks
Invoke the limiter programmatically in custom services using rateLimit from apps/api/src/lib/rate-limit/index.ts:
import { rateLimit } from "../lib/rate-limit";
import { getPolicy } from "../lib/rate-limit/policies";
async function checkUserCanExport(userId: string) {
const policy = getPolicy("write-authed");
const { allowed, remaining, resetMs } = await rateLimit({
policy: "write-authed",
subjectId: userId,
});
if (!allowed) {
throw new Error(
`Rate limit exceeded – ${remaining} left, resets in ${resetMs / 1000}s`,
);
}
}
Extending the System: Adding Custom Policies
To create a new rate-limit tier, modify apps/api/src/lib/rate-limit/policies.ts:
// 1️⃣ Extend the PolicyId union
export type PolicyId =
| "default-anon"
| "default-authed"
// …existing IDs…
| "my-custom-policy";
// 2️⃣ Add the policy definition
export const POLICIES: Record<PolicyId, RateLimitPolicy> = {
// …existing entries…
"my-custom-policy": {
id: "my-custom-policy",
limit: 50,
windowMs: 60_000,
subject: "ip",
description: "Custom policy – 50 req/min per IP.",
},
};
Then apply it using rateLimiterFor("my-custom-policy") in your route definition.
Key Implementation Files
| File | Role |
|---|---|
apps/api/src/middleware/rate-limiter.ts |
Core middleware mapping policies to subjects and returning 429 responses |
apps/api/src/lib/rate-limit/index.ts |
Facade selecting Redis/memory backends and providing graceful shutdown |
apps/api/src/lib/rate-limit/policies.ts |
Catalog of named policies and metadata |
apps/api/src/lib/rate-limit/memory-store.ts |
In-process storage for development |
apps/api/src/lib/rate-limit/redis-store.ts |
Production-grade Redis atomic operations |
apps/api/src/modules/system/system.routes.ts |
Example integration showing middleware wiring |
apps/api/src/modules/system/rate-limit.controller.ts |
Controller for reading/updating per-server limits |
apps/api/src/lib/request-context.ts |
Utility supplying userId and organizationId for subject resolution |
Summary
- Openship implements API endpoint protection through a centralized middleware layer in
apps/apithat supports both Redis and in-memory backends. - Policy enforcement relies on two entry points:
rateLimiterFor()for specific routes and a global limiter for catch-all protection. - Subject resolution maps requests to buckets by IP, user, organization, or global cluster identifiers.
- Health endpoints are automatically exempt, and loopback development traffic bypasses limits to prevent local testing friction.
- HTTP 429 responses include standard
Retry-AfterandX-RateLimit-*headers when theenforcefunction detects exceeded limits.
Frequently Asked Questions
How does Openship handle rate limiting in development environments?
When env.TRUST_PROXY is false and requests originate from the loopback interface, the middleware in rate-limiter.ts bypasses standard limits to prevent developer workstations from exhausting buckets during local testing. The system still enforces authentication gates but ignores per-IP counters for localhost traffic.
Can I use Redis clustering for the rate-limit backend?
Yes. The redis-store.ts implementation supports production SaaS deployments using Redis atomic commands. Configure the connection via environment variables, and the pickBackend function in rate-limit/index.ts will automatically select the Redis store when OPENSHIP_RATE_LIMIT_STORE=redis is set or when Redis credentials are detected.
What headers does Openship return when rate limits are exceeded?
When a request exceeds the policy limit, the middleware returns HTTP 429 Too Many Requests with Retry-After indicating seconds until reset, plus X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers. These follow standard conventions for API rate-limit communication.
How do I exempt specific endpoints from rate limiting?
Add the endpoint pattern to the exemption check in apps/api/src/middleware/rate-limiter.ts (lines 34–37) alongside the existing /api/health/* check. Alternatively, use the secureRouter configuration without a rateLimit key to inherit the default policy, or bypass the global limiter entirely by not applying rateLimiterFor to the route.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →