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 287-vector corpus includes 275 generic runner fixtures — including SP09 MCP, SP12 OAuth, and SP13 objectstore across Go, TypeScript, and Python — plus the twelve nested MCP route-algebra and auth-facade tables.
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 the minimal tier, not a protected tier. It is the frozen legacy pipeline and does not evaluate
route-level transport postures: nothing about P0 adds an iam/jwt route guard, so an application that explicitly
selects P0 must supply its own authentication (for example oauth.RequireBearerTokenMiddleware) or own transport
authentication itself. P0 is appropriate for tightly scoped functions that do their own auth, observability, and
shedding. The default tier (P2) and SecureApp are the paths that evaluate the transport posture and
its defense-in-depth pairing; the MCP facade helper states the same caveat for the facade.
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 |
Streaming responses through buffered adapters
HTTP API v2 (payload format 2.0) and the buffered Lambda Function URL path deliver
buffered responses only — they cannot stream incrementally. The adapters therefore
drain a streaming response body (BodyReader/BodyStream/bodyStream/body_stream)
into the buffered body with a bounded budget instead of silently dropping it:
- a terminating stream is drained and delivered as the buffered response body (bounded to 4 MiB / 5 seconds; the byte budget and time budget are identical in all three runtimes);
- a stream that does not terminate within the budget (a live session listener,
an open replay) or a stream that errors fails closed with HTTP 500 and the
nested AppTheory error body:
{"error":{"code":"app.internal","message":"streaming response body cannot be delivered by the HTTP API v2 adapter"}}(the Function URL adapter names itself:"...cannot be delivered by the Function URL adapter"); - a stream whose total length exceeds 4 MiB maps to HTTP 413 payload too large
with the framework’s size error body:
{"error":{"code":"app.too_large","message":"response too large"}}, matching theMaxResponseBytessize semantics used elsewhere in the runtime; - a stream that closes empty only because the deadline fired fails closed as well,
so the empty-
200reconnect loop cannot reappear at the deadline boundary.
No adapter performs an unbounded read, and none of the budgets are configurable: a handler that wants true incremental SSE must use a response-streaming adapter (API Gateway REST v1, or the Lambda Function URL streaming handler), not HTTP API v2.
The ALB target group and buffered API Gateway REST v1 conversions (and
the WebSocket adapter, which returns the v1 proxy shape) deliver a streaming body
through the same bounded drain as the shapes above: a terminating body becomes the
buffered response, a body over 4 MiB maps to 413, and a body that does not
terminate in time fails closed with the adapter named in the message
("...cannot be delivered by the ALB target group adapter" /
"...by the API Gateway REST v1 adapter"). No buffered adapter drops a streaming
body.
When the time budget expires, the adapter does not walk away from the body: it closes the reader (or the stream) so the producer can unwind and then joins the read it gave up on for a bounded grace window (100 ms in all three runtimes) before failing closed. A producer that observes the close settles well within that window and is never left running. A body that is neither closable nor terminating — a handler-supplied source blocked inside a call it does not leave — cannot be interrupted by construction; the adapter stops waiting at the grace window, does not detach that producer, and does not claim it terminated, so the Lambda function timeout is what bounds it rather than the adapter. The wait is bounded rather than unconditional: an unbounded join lets a single stalled stream hold the invocation until the function timeout, which is the availability regression this bound prevents. Every body the runtime itself produces is closable, and every wrapper the runtime puts around a body forwards close to the body it wraps. See Invocation-scoped work.
Timeout middleware
TimeoutMiddleware (Go/TypeScript) and timeout_middleware (Python) fail a
request closed when its timeout policy expires. The handler chain runs on a worker
with a deadline-bearing context, and the middleware reports app.timeout/HTTP 408
at the deadline — not after the handler returns. A handler that observes its
canceled context returns immediately and is never left running; a handler that
ignores cancellation is exactly the case the middleware exists to bound. After the
deadline the middleware joins the worker for a bounded grace window (250 ms in all
three runtimes) before it returns the timeout response, so an uncooperative
handler cannot hold the invocation open. A synchronous handler with no
cancellation point cannot be interrupted by the middleware by construction; it is
the documented residual, bounded by the Lambda function timeout rather than by the
middleware waiting for it.
The invocation-scope guard
scripts/verify-invocation-scope.sh is part of make rubric. It runs
scripts/tools/invocation_scope over runtime/, pkg/, testkit/, cmd/,
ts/src/ and py/src/, and fails on any asynchronous launch whose join the
proof cannot show. scripts/invocation-scope-baseline.txt lists exactly the
sites the proof cannot discharge, each with the join that keeps it inside its
invocation. A site is absent from that file only when the proof itself discharges
it, so the baseline shrinks as the runtime makes a join visible to a parser, and
a launch the proof cannot follow — even one whose join is in the same function —
is listed there with its strict join test rather than left unreported.
Each language is read with its own parser, never with a text pattern:
- Go —
go/ast. A launch is joined only when a join dominates every exit of the function that launched it: aWait()whoseAddruns before the launch and whose goroutine callsDone, adefer <join>registered before any return and reached on every path, afor rangedrain (or, for a close-only goroutine, a receive) over an unbuffered channel the goroutine closes as its last action, aStop()or channel receive that releases anAfterFunc/NewTimer/NewTickerhandle on every path, or an errgroupWait. Recognized regardless of an aliasedtimeimport. - TypeScript — the TypeScript compiler API, through
scripts/tools/invocation_scope/scan_typescript.mjs. Each scope (the module, every function, every parameter initializer, every class-field initializer and every static block) gets a control-flow graph; a launch is joined only when anawait(orreturn) of the held target, or aclearTimeout/clearInterval/clearImmediateof a timer handle, dominates every exit.await Promise.all/allSettledover the target joins it;Promise.race/anysettles on the first input and joins none of the losing ones. A target and its join are matched on the bare expression, so a non-null assertion, a type assertion or parentheses around either name the same target (clearTimeout(this.t!)clears thethis.tthat armed the timer). A timer function bound to a variable (const st = setTimeout; st(cb, 1)) is recognized as that timer, a member assigned an async arrow (this.run = async () => {...}) is a function the file declares async, and an iterator read held from<expr>.next()is a launch until it settles. A TypeScript parse diagnostic fails the scan rather than being skipped. - Python — the standard-library
astmodule, throughscripts/tools/invocation_scope/scan_python.py, with the same per-scope control-flow graph. Threads are joined byjoin(), tasks and offloads byawait(directly or throughgather/wait), and an executorsubmitis joined lexically by its ownwithblock. A class body and the expressions adef/classstatement evaluates (default arguments, decorators, class bases and keywords) are scopes of their own; a walrus receiver names its target for.start()((t := Thread(...)).start()), while a.join()receiver is credited to the value the walrus binds rather than the assignment target, so(t := other).join()does not join a thread started ast. A plain name is tracked inside the function that binds it, plus the body of thedefwhose default argument bound it; an attribute (self.worker,mod.worker), a container element (pool["a"]), a module global and a class attribute (also underself.<name>and<Class>.<name>) are tracked across the file, because the target belongs to the instance, the container, the module or the class rather than to the frame that assigned it. The join proof stays in the function that starts the work: a join in a sibling method does not clean a launch.
A missing node or Python interpreter fails the guard; it never skips a
language, and it never reports an empty (vacuously clean) result. The guard’s
self-tests in scripts/tools/invocation_scope pin a red case for every shape a
presence- or line-based reading accepts by mistake — a one-line
if (cond) await p, a join after an early return, a join inside a nested
uncalled function, a cancel() without a wait, a conditional Stop(), a
destructuring or array aggregate of promises, a walrus-bound task, a target bound
in one method and started in a sibling method, a launch in a default argument, a
decorator or a class base, a launch in a parameter initializer, a timer reached
through a variable alias — and a green case for every joined form
(try/finally, both branches of an if/else, await Promise.all/
allSettled, defer wg.Wait(), a sibling method that starts and joins the
target its constructor stored). main_test.go holds the Go proofs and
selftest.go the two scanner batteries; the battery runs from
scripts/verify-invocation-scope.sh because it needs the TypeScript compiler and
a Python interpreter, neither of which the release-gates snapshot (a tracked-tree
copy with no node_modules) provides. make rubric runs that verifier;
make test-unit is go test only and does not run the scanners at all.
Deliberately conservative, and reported rather than assumed joined:
- An exception raised outside a
trybody is not modeled as an exit, mirroring the Go proof. - A
trywhose handlers are not exhaustive keeps a transfer edge to the exit, so a join after such atryis reported even though a catch-all would have discharged it. - A thread built inside a comprehension is reported even when a later loop starts and joins it, because the comprehension hides the handle the guard tracks.
- A join that crosses a scope boundary — into another method, into a callback the
caller may never call, into a handler that only some paths reach, or into a
returned cleanup function — is reported, and so is a join matched only through a
chain that consumes its target (
await p.then(f)is not read as a join ofp). The join may well be real; the proof simply cannot show it, so the site stays in the baseline with its strict join test. That is the shape of every remaining baseline entry: the Go producers joined by another method or by the consumer of a returned value, and the two TypeScript drain sites — the budget timer armed in aPromiseexecutor and cleared in the race’sfinally, and the in-flight read the drain releases only on its budget path. - A daemon-flagged thread constructor in a default argument, a decorator or a
class base is reported even without a
.start(), because the expression runs outside every body the proof covers. - A labeled
break/continueis modeled as leaving the scope rather than as loop control, so a join after the labeled statement is not credited to a launch before it: the launch is reported. The conservative direction is deliberate, and a probe pins it. - An attribute, container element, module global or class attribute is tracked file-wide, so a scope that rebinds the same key does not un-track it, and a subscript whose key the proof cannot match is recognized as a launch whenever the container it indexes holds one.
unref()is not a join: it releases the event loop, not the callback.- A
switchis modeled without fallthrough, and a promise whose producer is known only from its type (not from syntax) is left to@typescript-eslint/no-floating-promisesinsidets/**.
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 — 287 vectors: 275 generic fixtures across Go/TS/Python plus twelve nested MCP route-algebra and auth-facade tables