Users' guide

How a request is processed

This page follows one HTTP request through ScalWS, from the accepted connection to the logged response, and lists the status code returned at every step that can refuse it.

The order is fixed. A request refused at one step never reaches the later ones: a request over the per-client rate is not routed, a denied path never reaches the response cache, and no application code runs before every check has passed.

Error answers produced by ScalWS itself are short text/plain; charset=utf-8 bodies made of the status code and its reason phrase, for example 404 Not Found.

Connection

These steps run once per connection. A refusal here closes the connection (or, for HTTP parsing errors, answers and then closes it); no request reaches the pipeline, so nothing appears in the access log. Each refusal is counted in scalws_rejections_total with the reason given below (see Logging and metrics).

  1. Accept and the global connection limit. Every listener shares one pool of server.limits.max_connections slots (HTTP/3 connections included). When all are taken, the accept loop pauses until a connection closes; waiting connections stay in the kernel backlog. Counted once per pause with reason connection_limit.

  2. PROXY protocol (listeners with proxy_protocol). A peer outside server.trusted_proxies is closed at once (untrusted_proxy). A missing, malformed or late header (it must arrive within header_read_timeout) closes the connection (proxy_protocol). The address in the header becomes the connection’s client address.

  3. Connections per client address. server.security.per_ip.max_connections limits open connections per source (IPv4 address, or IPv6 /64). Over the limit the connection is closed at once (source_connection_limit). Connections from trusted proxies are not limited per source: a load balancer carries many clients.

  4. TLS handshake (TLS listeners). It must finish within server.limits.tls_handshake_timeout (tls_handshake_timeout); a failed handshake is counted as tls_handshake. The certificate is chosen from the SNI name, and a handshake fails when no certificate matches and the listener has no default one: see Domains, routing and default certificates. ALPN h2 selects HTTP/2; http/1.1 or no ALPN selects HTTP/1.1. On plain TCP, a connection that starts with the HTTP/2 preface (PRI) is served as HTTP/2 with prior knowledge, anything else as HTTP/1.1; the first bytes must arrive within header_read_timeout (header_timeout). HTTP/3 (QUIC) handshakes have the same bound (tls_handshake_timeout). A QUIC handshake from an address that has not been validated yet holds a connection permit only while such handshakes, over all HTTP/3 listeners of the instance, use less than a quarter of max_connections, while fewer than that many started within the last tls_handshake_timeout, and only while its source’s open connections and unvalidated handshakes stay below the per-source limit (step 3); otherwise it is answered with a Retry, which only a client that receives at its address can follow, so forged source addresses cannot use up the permits. A client that follows the Retry comes back validated and is admitted or refused at once, before any TLS work. The per-source slot is taken once the handshake has validated the address, so a forged address never takes another’s slots.

  5. HTTP parsing and limits.

    • The request head is limited by server.limits.max_header_bytes and server.limits.max_headers. On HTTP/1 a head that is too large is answered with 431, a malformed one with 400, and the connection is closed (bad_request). On HTTP/2, max_header_bytes is the header list size limit. On HTTP/3 it is the field section size limit, and any frame but DATA that declares more than max_header_bytes plus 1 KiB resets its stream (H3_EXCESSIVE_LOAD) before it is read.
    • server.limits.header_read_timeout: an HTTP/1 request head must be complete within this time, otherwise the connection is closed (header_timeout).
    • server.limits.idle_timeout closes HTTP/1 and HTTP/2 connections without a request in flight; server.limits.write_timeout closes a connection whose writes make no progress; on HTTP/2 and HTTP/3 a response whose body makes no send progress (a client that grants no flow-control credit makes nothing to write) is ended alone, the connection’s other streams and requests going on (an HTTP/2 connection is closed when all its responses are stalled and no request is in service, or after idle_timeout when it has only such responses left); the body goes out in steps of 16 KiB, each of which must be sent within the timeout, and the response and the tenant’s concurrency slot are released.
    • server.limits.h2_max_concurrent_streams bounds concurrent streams per HTTP/2 (and HTTP/3) connection.

    With server.http1_server: experimental, HTTP/1 connections are served by ScalWS’s own HTTP/1 implementation (ADR-0046) with the same limits and the same answers (431, 400, then close).

Request

Every request that was parsed enters the same pipeline, whatever the protocol (HTTP/1.1, HTTP/2 or HTTP/3). It gets a request id first: 24 hexadecimal characters, returned in the x-request-id response header and written to the access log.

StepCheckAnswer
1ACME HTTP-01 challenge200 with the key authorization
2Client address and trusted proxies(no answer)
3Per-client request rate429
4CONNECT405
5URI length414
6Host header400
7Path normalisation400
8Host lookup404
9Strict SNI421
10Denied paths404
11Per-path rate limits429
12https_redirect301
13.htaccess403, redirects, 400, 500, …
14Route selection(no answer)
15Route allow / deny403
16Route CORS preflight204
17Route basic authentication401 or 503
18Request body size (route max_body)413 or 400
19Tenant quotas429 or 503
20Route rewrite and request headers400, 404
21Fixed answersthe respond answer
22Response cachecached response
23Handler (route request_timeout)the application’s answer, 504
24.htaccess error documents and headers(changes the answer)
25Response headers, metrics, access log(route response_headers and CORS headers)

In detail:

  1. ACME HTTP-01 challenge. On a plain-HTTP connection, GET /.well-known/acme-challenge/<token> for a token of a pending order is answered with 200 and the key authorization (application/octet-stream). This happens before the host check and before https_redirect, so validation also works for applications that redirect to HTTPS. Unknown tokens and other /.well-known/ paths continue through the pipeline to the application. See tls.md and server.acme.

  2. Client address. When the connection’s peer is in server.trusted_proxies, the client is the right-most X-Forwarded-For entry that is not itself a trusted proxy; if that entry cannot be parsed, the client is unknown (::), never the proxy. From any other peer, X-Forwarded-For is ignored and the peer is the client. This address is used by every later check (rate limits, .htaccess Require ip), the access log and the handlers (ADR-0022).

  3. Per-client request rate. server.security.per_ip.requests_per_second and burst: a token bucket per client address (IPv6 per /64). Over the rate: 429 with Retry-After (whole seconds, at least 1). Counted in scalws_security_blocked_total with reason="ip_rate".

  4. CONNECT is refused with 405: ScalWS is not a forward proxy.

  5. URI length. A path and query longer than server.limits.max_uri_bytes gets 414.

  6. Host. The host is taken from the request target when it carries one (absolute-form URI, HTTP/2 and HTTP/3 :authority), otherwise from the Host header. 400 when there is no host, when there is more than one Host header, when Host differs from the request target’s authority, or when the host is not a valid name or address (see domains.md).

  7. Path normalisation. The path is percent-decoded, . and .. segments are resolved and repeated slashes collapsed. 400 for a path that does not start with /, an invalid percent escape, an encoded NUL byte, or .. above the root. All later checks and route matching use the normalised path.

  8. Host lookup. The normalised host is looked up among the domains of all applications: exact names first, then one-label wildcards. A host that no application serves gets 404. See domains.md.

  9. Strict SNI. On a listener with strict_sni: true, a request whose host differs from the TLS SNI name of its connection gets 421 Misdirected Request; the client retries on a new connection. Connections without SNI are not affected. Counted with reason="misdirected".

  10. Denied paths. security.deny_paths (and the rules of the application’s profile): 404. A directory request is also refused when one of its index files is denied (/uploads/ would run /uploads/index.php). Counted with reason="denied_path".

  11. Per-path rate limits. security.rate_limits, per client address: 429 with Retry-After. Counted with reason="route_rate".

  12. HTTPS redirect. With https_redirect: true, a plain-HTTP request gets 301 with Location: https://<host><path and query>. The host is the normalised one and no port is kept.

  13. .htaccess (applications with htaccess: true, ADR-0031). Access rules answer 403; Redirect and RewriteRule with [R] answer with their redirect status and Location; flags such as [F] and [G] answer with their status; a rewrite to an invalid path answers 400 and a rewrite loop 500. A rewritten path is checked against the denied paths again (404). Otherwise the request continues with the rewritten path and query. See applications.md.

  14. Route selection on the normalised path (after .htaccess rewrites): exact routes first, then prefix routes (longest first), then regex routes in the order they are written; a route whose method, header or query matchers do not match is skipped. Requests matched by no route use the application’s runtime, or get 404 when it has none. See configuration.md. Steps 15 to 20 apply the policies of the selected route (route policies, ADR-0050); a route without policies skips them. When .htaccess rewrote the request, the route the client’s own path selects keeps its allow/deny (step 15, both routes’ must pass) and its basic_auth (step 17, checked instead of the selected route’s).

  15. Route address lists. A client address (after trusted proxies) in the route’s deny, or outside its allow when allow is set, gets 403. Counted with reason="route_access".

  16. CORS preflight. On a route with cors, an OPTIONS request with Origin and Access-Control-Request-Method is answered with 204 and the CORS headers (only for an allowed origin); it never reaches the runtime.

  17. Basic authentication. On a route with basic_auth, missing or wrong credentials get 401 with WWW-Authenticate (counted with reason="basic_auth"); when 64 password checks are already running, 503 with Retry-After: 1 (reason="basic_auth_busy"). Accepted credentials are removed: the runtime never sees the Authorization header.

  18. Request body size. A Content-Length above the route’s max_body, or else server.limits.max_request_body_bytes, gets 413 before any body byte is read; an invalid Content-Length gets 400. A body without a length is counted while it streams: it is cut off at the limit, and a gap longer than server.limits.body_read_timeout between two chunks ends it. The proxy and PHP handlers then answer 413 or 408. A route’s max_body may be larger or smaller than the global limit.

  19. Tenant quotas. requests_per_second (with burst) answers 429 with Retry-After; max_concurrent_requests answers 503 with Retry-After: 1 when all slots are in use. Counted in scalws_tenant_limited_total with reason rate or concurrency. A concurrency slot is held until the response body is finished.

  20. Route rewrite and request headers. The route’s rewrite replaces the path (and maybe the query) and sets the upstream Host; a capture that would form a . or .. segment, or a result above /, gets 400. The rewritten path is checked against the denied paths again (404, reason="denied_path"); per-path rate limits and .htaccess are not run again. Then the route’s request_headers are applied (by proxies after their X-Forwarded-* headers; for every other runtime, PHP included, here).

  21. Fixed answers. In an application without .htaccess, a respond runtime answers here directly.

  22. Response cache (with server.cache and the application’s cache). A fresh entry is answered from the cache; otherwise the handler runs and its response may be stored. respond routes never use the cache, static files only with a default_ttl, and routes with basic_auth, allow or deny never. When the application’s routes are not all plain prefixes, entries are kept per route. The answer carries x-cache: HIT, STALE, MISS or BYPASS. See caching.md.

  23. Handler.

    • static: 405 (with Allow: GET, HEAD) for other methods, 404 for missing or denied files, 403 when the file cannot be read, 301 to add the trailing slash of a directory, 304, 206 and 416 for conditional and range requests.
    • proxy, php, node, python, container: the application’s answer, or 502 when the upstream cannot be reached or answers with an invalid response, 504 on a response timeout, 503 with Retry-After: 1 when no worker is ready or an upstream pool has no member. These are counted in scalws_upstream_failures_total. A pool may retry on another member first (load-balancing.md).
    • respond and redirect: their configured answer.

    A route’s request_timeout bounds steps 22 and 23 until the response head: 504 when it passes. The response body is not bounded. See applications.md.

  24. .htaccess after the handler. An ErrorDocument for the response status replaces the body (a local document keeps the original status, a URL redirects with 302), and Header directives are applied.

  25. Response. Server: scalws is added unless the handler set its own Server header, then x-request-id; then the matched route’s response_headers (also on answers ScalWS generated in steps 15 to 23) and, on a route with cors, its CORS headers and Vary: Origin. Alt-Svc is added on TLS listeners that also serve HTTP/3. Request metrics are recorded when the response head is ready. The access log line (with server.logging.access_log) and the per-domain byte counters are written when the response body is finished, or when it is dropped because the client went away. See Logging and metrics.

Requests refused in steps 1 to 8 have no application yet: they appear in metrics and in the access log with tenant and application _unmatched.

See also