Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 50 additions & 16 deletions docs/RATE_LIMITING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,32 +11,47 @@ REDIS_URL=redis://default:password@redis:6379
RATE_LIMIT_KEY_PREFIX=stellaiverse:rate-limit
```

Redis is required for decorated routes. The limiter deliberately does not fall
back to a process-local counter because that would silently multiply limits by
the number of running API instances.
## Keying strategies

## Identifiers
Use the `key` option on `@RateLimit` to control how requests are grouped:

The guard selects one identifier in this order:
- `user` — one bucket per authenticated user ID (`user:<id>`).
- `ip` — one bucket per client IP (`ip:<ip>`).
- `api-key` — one bucket per SHA-256 digest of `X-API-Key` (`api-key:<digest>`).
- `global` — a single shared bucket (`global:default`) for route-wide throttling.

1. Authenticated user ID, stored as `user:<id>`.
2. `X-API-Key`, stored as `api-key:<sha256>` so the credential is never written
to Redis or logs.
3. The first `X-Forwarded-For` address or the request IP, stored as `ip:<ip>`.
When `key` is omitted, the guard auto-selects the identifier in this order:

Only trust `X-Forwarded-For` when the application is behind a configured trusted
proxy that replaces client-supplied forwarding headers.
1. Authenticated user ID
2. `X-API-Key`
3. `X-Forwarded-For` / `req.ip`

Example global throttle:

```ts
@RateLimit({ limit: 1000, windowMs: 60_000, key: "global" })
```

## Resilience and fallback

If Redis is unavailable, the rate limiter falls back to an in-memory token
bucket so the API remains available. Fallback decisions are recorded with
`reason: "fallback"` and emitted as metrics. Because in-memory counters are
local to each process, limits are not shared across instances during a Redis
outage.

## Policies and algorithms

`@RateLimit` accepts a request limit, window, burst allowance, and algorithm:
`@RateLimit` accepts a request limit, window, burst allowance, algorithm, and
key strategy:

```ts
@RateLimit({
limit: 100,
windowMs: 60_000,
burst: 20,
algorithm: "token-bucket",
key: "ip",
})
```

Expand All @@ -59,9 +74,27 @@ Decorated responses include:
- `X-RateLimit-Reset`, as a Unix timestamp in seconds
- `Retry-After`, in seconds, on HTTP 429 responses

Prometheus exposes `stellaiverse_rate_limit_decisions_total` and
`stellaiverse_rate_limit_rejections_total` through the existing `/metrics`
endpoint.
Prometheus exposes the following metrics through the existing `/metrics`
endpoint:

- `stellaiverse_rate_limit_decisions_total` — total rate-limit decisions
- `stellaiverse_rate_limit_rejections_total` — total 429 responses
- `stellaiverse_rate_limit_redis_errors_total` — total Redis errors
- `stellaiverse_rate_limit_fallback_total` — total fallback decisions
- `stellaiverse_rate_limit_redis_duration_seconds` — Redis operation latency

## High-traffic endpoints

For high-traffic routes:

1. Use `key: "global"` or `key: "ip"` to limit explosion of Redis keys.
2. Keep `burst` small to smooth traffic spikes.
3. Prefer wider windows (e.g., `windowMs: 60_000`) over short windows to reduce
Lua script execution frequency.
4. Monitor `stellaiverse_rate_limit_redis_duration_seconds` and alert if p99
exceeds 10 ms.
5. If Redis latency rises, the automatic fallback keeps the API serving, but
consider scaling Redis or adding a read replica.

## Administration

Expand Down Expand Up @@ -96,4 +129,5 @@ decision takes precedence if inconsistent legacy data contains both entries.
The unit suite uses two limiter service instances sharing one atomic test store
and submits concurrent requests. Exactly the configured capacity is accepted.
The Redis store test separately verifies that each production decision uses one
Lua `EVAL`, which is the cross-instance atomicity boundary.
Lua `EVAL`, which is the cross-instance atomicity boundary. Additional tests
cover global key strategy, Redis failure fallback, and memory store behavior.
1 change: 1 addition & 0 deletions src/common/decorators/rate-limit.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface RateLimitOptions {
windowMs?: number;
burst?: number;
algorithm?: "token-bucket" | "leaky-bucket";
key?: "user" | "ip" | "api-key" | "global";
}

const TIER_CONFIG: Record<SensitiveTier, { limit: number; ttl: number }> = {
Expand Down
63 changes: 63 additions & 0 deletions src/common/guard/quota.guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,67 @@ describe("QuotaGuard", () => {
expect.anything(),
);
});

it("uses global key strategy when specified", async () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue({
limit: 10,
windowMs: 60_000,
key: "global",
});
(rateLimiterService.checkQuota as jest.Mock).mockResolvedValue({
allowed: true,
limit: 10,
remaining: 9,
resetMs: 6_000,
});
const context = {
getHandler: jest.fn(),
getClass: jest.fn(),
switchToHttp: jest.fn().mockReturnValue({
getRequest: jest.fn().mockReturnValue({
ip: "127.0.0.1",
headers: {},
user: { id: 1 },
}),
getResponse: jest.fn().mockReturnValue({ header: jest.fn() }),
}),
} as any;

await guard.canActivate(context);

expect(rateLimiterService.checkQuota).toHaveBeenCalledWith(
"global:default",
10,
60_000,
expect.any(Number),
"token-bucket",
);
});

it("emits fallback metric when Redis fallback is triggered", async () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue({
limit: 10,
windowMs: 60_000,
});
(rateLimiterService.checkQuota as jest.Mock).mockResolvedValue({
allowed: false,
limit: 10,
remaining: 0,
resetMs: 60000,
reason: "fallback",
});
const mockResponse = {
header: jest.fn(),
};
const context = {
getHandler: jest.fn(),
getClass: jest.fn(),
switchToHttp: jest.fn().mockReturnValue({
getRequest: jest.fn().mockReturnValue({ ip: "127.0.0.1", headers: {} }),
getResponse: jest.fn().mockReturnValue(mockResponse),
}),
} as any;

await expect(guard.canActivate(context)).rejects.toThrow(HttpException);
});
});
33 changes: 31 additions & 2 deletions src/common/guard/quota.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { RateLimiterService } from "src/quota/rate-limiter.service";
import {
rateLimitDecisionsTotal,
rateLimitRejectionsTotal,
rateLimitFallbackTotal,
} from "src/config/metrics";

@Injectable()
Expand Down Expand Up @@ -51,7 +52,7 @@ export class QuotaGuard implements CanActivate {
}

const request = context.switchToHttp().getRequest();
const trackerKey = this.getTrackerKey(request);
const trackerKey = this.getTrackerKey(request, options);

// Merge options with level config
const levelConfig =
Expand Down Expand Up @@ -185,6 +186,12 @@ export class QuotaGuard implements CanActivate {
policy,
reason: result.reason ?? "limited",
});
if (result.reason === "fallback") {
rateLimitFallbackTotal.inc({
identifier_type: trackerKey.split(":", 1)[0],
reason: "redis_unavailable",
});
}
this.metrics?.rateLimitExceeded.inc({
policy,
user_tier: userTier,
Expand Down Expand Up @@ -294,7 +301,29 @@ export class QuotaGuard implements CanActivate {
return true;
}

private getTrackerKey(req: any): string {
private getTrackerKey(req: any, options?: RateLimitOptions): string {
const strategy = options?.key;
if (strategy === "global") {
return "global:default";
}
if (strategy === "ip") {
const xff = req.headers?.["x-forwarded-for"];
const ip = typeof xff === "string" ? xff.split(",")[0].trim() : req.ip;
return `ip:${ip || "unknown"}`;
}
if (strategy === "api-key") {
const apiKey = req.headers?.["x-api-key"];
if (typeof apiKey === "string" && apiKey.length > 0) {
const digest = createHash("sha256").update(apiKey).digest("hex");
return `api-key:${digest}`;
}
return `api-key:unknown`;
}
if (strategy === "user") {
const userId = req.user?.id;
return `user:${userId || "unknown"}`;
}

const userId = req.user?.id;
if (userId) {
return `user:${userId}`;
Expand Down
22 changes: 22 additions & 0 deletions src/config/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,25 @@ export const rateLimitRejectionsTotal = new client.Counter({
labelNames: ["policy", "reason"],
registers: [register],
});

export const rateLimitRedisErrorsTotal = new client.Counter({
name: "stellaiverse_rate_limit_redis_errors_total",
help: "Total Redis errors encountered by the distributed rate limiter",
labelNames: ["operation"],
registers: [register],
});

export const rateLimitFallbackTotal = new client.Counter({
name: "stellaiverse_rate_limit_fallback_total",
help: "Total fallback decisions made when Redis was unavailable",
labelNames: ["identifier_type", "reason"],
registers: [register],
});

export const rateLimitRedisDuration = new client.Histogram({
name: "stellaiverse_rate_limit_redis_duration_seconds",
help: "Duration of Redis rate-limit operations",
labelNames: ["operation"],
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1],
registers: [register],
});
53 changes: 52 additions & 1 deletion src/config/swagger.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,18 @@ export function setupSwagger(app: INestApplication): void {
const config = new DocumentBuilder()
.setTitle("StellAIverse Backend API")
.setDescription(
"Comprehensive API documentation for StellAIverse backend services including agent management, oracle submissions, compute operations, and audit trails",
"Comprehensive API documentation for StellAIverse backend services including " +
"portfolio management, agent management, oracle submissions, compute operations, " +
"and audit trails.\n\n" +
"## Portfolio Management\n" +
"Endpoints for portfolio CRUD operations, asset management, optimization, " +
"rebalancing, performance analytics, backtesting, and ML predictions.\n\n" +
"### Rate Limiting\n" +
"- **Global**: 100 requests/minute\n" +
"- **Trading (Portfolio)**: 20 requests/minute\n" +
"- **Auth**: 5 requests/minute\n\n" +
"### Authentication\n" +
"All portfolio endpoints require JWT Bearer token authentication.",
)
.setVersion("1.0.0")
.setContact(
Expand Down Expand Up @@ -41,6 +52,44 @@ export function setupSwagger(app: INestApplication): void {
.addTag("Oracle", "Oracle data submissions")
.addTag("Audit", "Audit trail and logging")
.addTag("Profile", "User profile management")
.addTag(
"Portfolio Management",
"Portfolio CRUD, optimization, rebalancing, and analytics. " +
"Includes endpoints for creating, reading, updating, and archiving portfolios, " +
"managing assets, running optimizations, and tracking performance.",
)
.addTag(
"Portfolio Assets",
"Asset (holding) management within portfolios. " +
"Supports multi-chain assets with cost basis tracking and unrealized gain/loss.",
)
.addTag(
"Portfolio Optimization",
"Portfolio optimization using Modern Portfolio Theory, " +
"Black-Litterman model, risk parity, and ML-based strategies.",
)
.addTag(
"Portfolio Rebalancing",
"Portfolio rebalancing triggers, execution, and history. " +
"Supports manual, time-based, threshold-based, and ML-triggered rebalancing.",
)
.addTag(
"Portfolio Analytics",
"Performance analytics including returns, volatility, Sharpe ratio, " +
"Sortino ratio, VaR, drawdown, and benchmark comparison.",
)
.addTag(
"Portfolio Backtesting",
"Historical backtesting of portfolio strategies with performance metrics.",
)
.addTag(
"ML Predictions",
"Machine learning-based price predictions for portfolio assets.",
)
.addTag(
"Portfolio Transactions",
"Transaction recording, history, cost basis calculation, and export.",
)
.build();

const document = SwaggerModule.createDocument(app, config, {
Expand All @@ -66,6 +115,8 @@ export function setupSwagger(app: INestApplication): void {
defaultModelsExpandDepth: 2,
defaultModelExpandDepth: 2,
tryItOutEnabled: true,
tagsSorter: "alpha",
operationsSorter: "alpha",
},
});
}
Loading
Loading