class

RinhaDeBackend::HttpServer

Inherits Reference < Object

Minimal HTTP/1.1 server tailored to the Rinha workload: two known routes (POST /fraud-score, GET /ready), tiny POST bodies, no query string, no chunked encoding, no upgrades.

Why we replaced HTTP::Server:

  • per-request Request/Response/header Hash allocations,
  • 8 KB IO::Buffered read+write buffers per kept-alive connection,
  • keep-alive race that occasionally RSTs sockets under load.

Design here:

  • One fiber per connection (same as stdlib).
  • Per-fiber 8 KB stack buffer (uninitialized StaticArray) — survives I/O yields, costs nothing to "allocate".
  • HttpParser (pure Crystal) parses the request line + headers from the same stack buffer, zero allocations on the hot path.
  • Six pre-rendered fraud-score responses + three static status-only responses, written with a single socket.write.
  • read_buffering = false, sync = true, tcp_nodelay = true: no internal IO::Buffered allocation per connection, no Nagle.
  • Keep-alive by default for HTTP/1.1; only Connection: close forces shutdown (or HTTP/1.0 unless Connection: keep-alive).

Constants

BAD_REQUEST_RESPONSE = "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_slice
BUF_SIZE = 8192

8 KB is overkill for the Rinha schema (request bodies fit in <1 KB, request lines + headers fit in ~600 B). Headroom is cheap since the buffer is on the fiber stack.

CLOSE_VALUE = "close"
CONNECTION_NAME = "connection"
CONTENT_LENGTH_NAME = "content-length"

Lowercase reference values for case-insensitive header matching.

DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 9999
FRAUD_BODIES = ["{\"approved\":true,\"fraud_score\":0.0}", "{\"approved\":true,\"fraud_score\":0.2}", "{\"approved\":true,\"fraud_score\":0.4}", "{\"approved\":false,\"fraud_score\":0.6}", "{\"approved\":false,\"fraud_score\":0.8}", "{\"approved\":false,\"fraud_score\":1.0}"]

Six possible outcomes (frauds_in_top_5 = 0..5). Threshold is 0.6, so 3+ frauds → not approved. The bodies match what FraudScoreAction had before; we just inline the HTTP envelope so the whole response can be written in a single syscall, no streaming, no header serialization.

FRAUD_RESPONSES = FRAUD_BODIES.map do |body| ("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: #{body.bytesize}\r\nConnection: keep-alive\r\n\r\n" + body).to_slice end
GET_PATH_READY = "/ready".to_slice
KEEP_ALIVE_VALUE = "keep-alive"
MAX_HEADERS = 16
NOT_FOUND_RESPONSE = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: keep-alive\r\n\r\n".to_slice
POST_PATH_FRAUD = "/fraud-score".to_slice
READY_RESPONSE = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: keep-alive\r\n\r\n".to_slice

Constructors

new(host : String, port : Int32, vectorizer : Vectorizer, ivf : Ivf, uds_path : String | Nil = nil)

Either listen on TCP (host/port) or on a Unix Domain Socket path. UDS mode is selected by passing a non-nil uds_path; in that mode host/port are ignored. The prod LB (HAProxy, see haproxy.cfg) talks to the API exclusively over UDS — TCP mode is kept as the dev/local path so specs/tools that don't go through the LB still work.

Source

Class methods

bytes_eq(a : Bytes, b : Bytes) : Bool
Source
ci_eq(a : Pointer(UInt8), b : Pointer(UInt8), len : Int32) : Bool
Source
parse_content_length(p : Pointer(UInt8), len : Int32) : Int32
Source
parse_request(buf : Bytes) : Tuple(Bytes, Bytes, Bytes, Bool) | Nil

Parses the request envelope from buf using HttpParser, then returns {method, path, body, connection_close} with byte slices pointing into buf. Returns nil on partial/invalid input. Used by specs to exercise the full parse path without TCP.

Source
scan_headers(headers : Pointer(HttpParser::Header), num : Int32, minor : Int32) : Tuple(Int32, Bool)

Header parsing helpers. Public class methods so spec/http_server_spec can exercise them with synthetic phr_header input — these are the pieces that bit us last time (Content-Length came back as 0, silent wrong answer downstream).

Source
trim_ows(p : Pointer(UInt8), len : Int32) : Tuple(Int32, Int32)

Returns [start, end) byte offsets after stripping leading and trailing OWS (space / tab) from p[0, len].

Source

Instance methods

listen

Listen with an optional post-bind / pre-accept callback. The callback runs after the server socket is bound (so the docker healthcheck test -S /sockets/api*.sock already passes) and before the accept loop starts (so the caller can do heavy warm-up like References#prefault! without delaying the healthcheck). The kernel queues incoming UDS connections in the backlog while the callback runs; the LB itself doesn't start until the API healthcheck passes, so by the time real traffic arrives the accept loop is already up.

Source
listen
Source