HTTP Runtime (P0–P2)
The HTTP runtime is AppTheory’s largest shared contract surface. It defines route matching, the middleware chain, request/response normalization, and the error envelope — and it is enforced identically in all three runtimes by the shared fixtures. The 223-fixture corpus also includes the SP09 MCP fixture tier, SP12 OAuth fixture tier, and SP13 objectstore tier executed by Go, TypeScript, and Python.
The runtime is tiered. You opt into a tier when you create the app:
| Runtime | Default | Override |
|---|---|---|
| Go | P2 | apptheory.New(apptheory.WithTier(apptheory.TierP0)) |
| TypeScript | P2 | createApp({ tier: "p0" }) |
| Python | P2 | create_app(tier="p0") |
The tier is a contract, not a menu. You do not invent a P1.5. If you need a capability from a higher tier without the full tier, check whether it is already available as a discrete primitive at the lower tier — if not, the right answer is to use the tier that contains it.
What each tier includes
P0 — minimal runtime
The smallest viable AppTheory:
- Path matching (literal and
{param}segments) - Method dispatch
- Request/response normalization (headers lower-cased, query parsed, body decoded)
- The AppTheory error envelope
SourceProvenance(see Source Provenance) — available even at P0- Strict route helpers (
GetStrict,handleStrict,handle_strict)
P0 is appropriate for tightly scoped functions that do their own auth, observability, and shedding.
P1 — production HTTP defaults
P0 plus:
- Request-id propagation —
x-request-idechoed on the response, generated when absent, and surfaced on the runtime context (ctx.RequestIDin Go,ctx.requestIdin TypeScript,ctx.request_idin Python). - Tenant extraction — convention-based tenant resolution from headers or the auth identity, available on the runtime context (
TenantID/tenantId/tenant_id). - Auth hooks — pluggable identity resolvers; the resolved identity is exposed on the runtime context (
AuthIdentity/authIdentity/auth_identity) or the request fails closed. - CORS — opt-in preflight handling and response header rewrites.
- Guardrails — request size and execution-time caps that fail closed before the handler runs.
- Middleware ordering — the framework-defined order. You do not insert “before request-id.” If the capability needs to run earlier, the tier model needs a new slot, and adding one is a contract change.
P2 — observability + load shedding (default)
P1 plus:
- Observability hooks — one request log record, one metric record, and one span-shaped record per completed HTTP
request, including
duration_msand inbound trace IDs extracted fromtraceparentorX-Amzn-Trace-Id. See Observability Hooks and Logging Profiles. - Rate-limit / load-shed hooks — the shared P2 contract pins the portable policy-hook outcome: a rejected request returns
app.rate_limited,429, andRetry-Afterwhile still flowing through observability. Go additionally exportsRateLimitMiddleware, which integrates withpkg/limitedand fingerprints default credential-derived identifiers (x-api-key,Authorization: Bearer) with HMAC-SHA256 before they reach the limiter. TypeScript and Python expose policy hooks plus limiter primitives, but they do not currently ship aRateLimitMiddlewareequivalent.
P2 is what production applications use unless they have a reason not to. The default is P2 because most consumers should not be assembling these pieces from scratch.
Route registration
app.Get ("/users/{id}", handler)
app.Post ("/users", handler)
app.Put ("/users/{id}", handler)
app.Patch ("/users/{id}", handler)
app.Delete("/users/{id}", handler)
app.Handle("GET", "/users/{id}", handler)
TypeScript: app.get, app.post, app.put, app.patch, app.delete, app.handle.
Python: app.get, app.post, app.put, app.patch, app.delete, app.handle (also usable as decorators).
If two routes are equally specific, the router prefers earlier registration order.
Fail-closed registration
Default fluent registration fails closed for invalid patterns, duplicate canonical method/pattern pairs, and nil,
undefined, or None handlers. Misconfigured applications that older v1 lines could silently ignore now fail during
startup or test setup instead of drifting into unexpected runtime 404s. Use the normal registration path in new code:
app.Get("/users/{id}", handler)
app.Handle("GET", "/users/{id}", handler)
app.get("/users/{id}", handler);
app.handle("GET", "/users/{id}", handler);
app.get("/users/{id}", handler)
app.handle("GET", "/users/{id}", handler)
The strict helpers remain only as deprecated compatibility wrappers for code that depends on their older
error-returning or throwing shape. Their failures use the canonical AppTheory error path: Python strict helpers raise
AppTheoryError, and Go strict helpers return canonical AppTheoryError messages where applicable.
Response helpers
apptheory.Text(200, "pong")
apptheory.JSON(200, map[string]any{"ok": true}) // may error on unmarshalable values
apptheory.MustJSON(200, map[string]any{"ok": true}) // panics on unmarshalable values
apptheory.Binary(200, body, "application/octet-stream")
apptheory.SSEResponse(/* … */)
TypeScript: text, json, html, binary, sse.
Python: text, json, html, binary, sse.
The error envelope
Default HTTP error responses use a nested envelope:
{
"error": {
"code": "not_found",
"message": "User not found",
"details": { "id": "u_42" }
},
"request_id": "req-1"
}
To match Lift’s flat shape (one-time migration aid only — not for new apps):
app := apptheory.New(apptheory.WithHTTPErrorFormat(apptheory.HTTPErrorFormatFlatLegacy))
const app = createApp({ httpErrorFormat: HTTP_ERROR_FORMAT_FLAT_LEGACY });
app = create_app(http_error_format=HTTP_ERROR_FORMAT_FLAT_LEGACY)
The default nested envelope remaps any error whose code string is EMPTY_BODY or INVALID_JSON to canonical
app.bad_request fields. The flat legacy HTTP format preserves those Lift-era codes/messages as a migration bridge.
The flat shape applies to HTTP only. AppSync and WebSocket error payloads keep their existing shapes regardless of
this setting — those surfaces have their own contracts.
HTTP entrypoints
You almost never need these directly — use HandleLambda / handleLambda / handle_lambda and let the runtime dispatch. But if your Lambda is single-trigger, the dedicated entrypoints are available:
| Concern | Go | TypeScript | Python |
|---|---|---|---|
| API Gateway v2 (HTTP API) | ServeAPIGatewayV2 |
serveAPIGatewayV2 |
serve_apigw_v2 |
| Lambda Function URL | ServeLambdaFunctionURL |
serveLambdaFunctionURL |
serve_lambda_function_url |
| API Gateway v1 (REST proxy) | ServeAPIGatewayProxy |
serveAPIGatewayProxy |
serve_apigw_proxy |
| ALB target group | ServeALB |
serveALB |
serve_alb |
Header canonicalization
Request.Headers and Response.Headers keys are lower-cased. Look-ups are case-insensitive at the boundary, but if you iterate the map you see the canonical (lower-case) form.
What’s not in scope
- CSRF protection — application concern; not in the runtime contract.
Forwarded/X-Forwarded-Fortrust — never; see Source Provenance.- Retries — handled by the AWS trigger configuration (DLQs, redrive policies), not by the runtime.
Next reads
- Source Provenance — safe HTTP client-IP access
- Observability Hooks — P2 duration, trace extraction, span/log records, and EMF sink boundaries
- Logging Profiles — profile-backed structured JSON log output
- Sanitization — safe logging helpers
- Event Workloads — the non-HTTP side of the runtime
- Contract Fixtures — the 223-fixture covenant, including MCP, OAuth, and objectstore fixtures across Go/TS/Python