HTTP API
Your controllers are plain ASP.NET Core (Server Setup). Altruist adds the parts every public API needs and nobody wants to write twice: one error shape, hardening (forwarded headers, security headers, body limits) and rate limits that hold across a fleet. Each part is off until you configure it, so existing apps answer exactly as before.
altruist:
server:
http:
errors:
format: simple
hardening:
server-header: false
forwarded-headers: { enabled: true }
security-headers: { enabled: true, hsts-max-age-seconds: 31536000 }
api: { path: /api, no-store: true, max-body-bytes: 16384 }
rate-limit:
policies:
api: { permit: 600, window-seconds: 60, path: /api }
auth: { permit: 10, window-seconds: 60 }
Errors
With errors:format: simple every failure of the API has the same body:
{ "error": "username_taken", "message": "That username is taken.", "field": "username" }
error is a stable code for your client, message is safe to show, field (camelCase, omitted when there is none) points at the input. Throw an HttpApiException wherever a request cannot be served:
using Altruist.Http;
if (await accounts.ExistsAsync(name))
throw new HttpApiException(409, "username_taken", "That username is taken.", field: "username");
if (!wallet.CanAfford(price))
throw new HttpApiException(409, "not_enough_gold", "You need more gold.");
throw new HttpApiException(429, "cooldown", "Try again soon.", retryAfterSeconds: 30); // + Retry-After: 30
Derive your own exception from it to keep factories in one place (public sealed class GameApiException : HttpApiException).
What Altruist answers for you:
| Situation | Status | error |
|---|---|---|
HttpApiException | its status (+ Retry-After) | its code |
Invalid model ([ApiController] validation, bad query values) | 400 | invalid_request, field = the first invalid field; none for malformed JSON |
| Body larger than the server's limit | 413 | payload_too_large |
| JWT bearer: no / invalid / expired token | 401 | unauthorized |
| JWT bearer: authenticated but not allowed | 403 | forbidden |
| Rate limit (below) | 429 (+ Retry-After) | rate_limited |
| The client aborted the request | 499, no body | — |
| Any other exception | 500 | internal (logged; the exception never reaches the client) |
Change the texts per code with errors:messages (e.g. rate-limited: "Slow down a little."). Register your own IAuthChallengeWriter to answer 401 / 403 differently. HttpErrorWriter (a singleton) writes the same bodies from your own middleware.
With format: none (the default) ASP.NET's own answers stay: problem details for invalid models, unhandled exceptions become 500s (the developer page in Development).
Hardening
altruist:server:http:hardening runs first in the pipeline — before the fleet relay, routing, authentication and the shields — in this order: forwarded headers, response headers, path rate limits, API body limit.
| Key | Default | Effect |
|---|---|---|
server-header | true | false drops Kestrel's Server header |
max-body-bytes | Kestrel's 30 MB | request body limit for every request |
forwarded-headers:enabled | false | trust X-Forwarded-For / -Proto from known proxies: ClientIp(), Request.IsHttps and secure cookies see the real client |
forwarded-headers:forward-limit | 1 | how many proxy hops to unwrap (e.g. 2 for tunnel → dev server → app) |
forwarded-headers:known-proxies | loopback | proxy addresses to trust besides loopback |
security-headers:enabled | false | X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, X-Frame-Options: DENY, Cross-Origin-Resource-Policy: same-origin on every response |
security-headers:hsts-max-age-seconds | 0 | Strict-Transport-Security: max-age=… on HTTPS requests |
api:path | — | the API's path prefix for the keys below |
api:content-security-policy | — | CSP for API responses, e.g. default-src 'none'; frame-ancestors 'none'; base-uri 'none' |
api:no-store | false | Cache-Control: no-store on API responses (an action may still set its own) |
api:max-body-bytes | — | larger bodies get 413 payload_too_large, declared (Content-Length) or streamed |
Note:
Only enable forwarded headers when the app is reachable only through your proxy (bind to localhost or a private network). Otherwise any client can claim any address and walk around per-address limits.
Rate Limits
Name a policy in config and put it on controllers or actions with [RateLimit("name")] — the same attribute that assigns portal buckets:
altruist:
server:
http:
rate-limit:
store: backplane # memory (default) | backplane
policies:
api: { permit: 600, window-seconds: 60, path: /api } # every request under /api
auth: { permit: 10, window-seconds: 60 } # per client address
crates: { permit: 60, window-seconds: 60, partition: principal } # per signed-in user
mail: { permit: 3, window-seconds: 3600, partition: principal }
[ApiController, Route("api/auth"), AllowAnonymous]
public sealed class AuthController : ControllerBase
{
[HttpPost("register"), RateLimit("auth")]
public Task<IActionResult> Register(RegisterRequest body) => ...;
[HttpPost("login"), RateLimit("auth")] // shares the 10 per minute with register
public Task<IActionResult> Login(LoginRequest body) => ...;
}
- Partition:
ip(default;HttpContext.ClientIp()) orprincipal(User.PrincipalId(), the address for anonymous requests). Policies on a controller and its action both apply. - When: after authorization and model validation (a 401 or an invalid body does not use up the budget), before the action runs.
- Rejected: 429
rate_limitedwithRetry-After(seconds); witherrors:format: nonea bare 429. path: the policy also applies to every request under that path, in the hardening middleware (before the API body limit).- Unknown names do not limit (a warning is logged once), like unconfigured portal buckets.
From your code
Some limits must count after your own checks — a "resend verification email" endpoint should say already verified rather than too many requests. Inject IRequestRateLimiter and hit the policy where it belongs:
public async Task ResendAsync(string accountId)
{
var account = await RequireAccountAsync(accountId);
if (account.EmailVerified)
throw new HttpApiException(409, "already_verified", "Your email is already verified.");
var limit = await limits.HitAsync("mail", accountId); // or HitAsync("mail", HttpContext)
if (!limit.Allowed)
throw new HttpApiException(429, "rate_limited", "Check your inbox first.", retryAfterSeconds: limit.RetryAfterSeconds);
...
}
One limit for the whole fleet
store | Windows | Several servers |
|---|---|---|
memory (default) | exact sliding windows | each server counts on its own: N servers allow N × the limit |
backplane | fixed windows aligned to the clock (Retry-After = until the window ends) | counted once in the fleet backplane (Redis) |
With backplane, the counters live in Redis under hashed keys. While the backplane is process-local (no fleet:redis) or unreachable, each server falls back to its own memory windows and retries Redis after 10 seconds, so a Redis outage never blocks a request.