Pular para o conteúdo

Stdlib · tier 1

http

cliente e servidor HTTP

official_libraries.md · 401 linhas · 11 min de leitura

use http

HTTP sobre a camada 2 do net (sockets de bytes + TLS, §4/net.md), parseando o protocolo ele mesmo. Não usa o Channel[T] tipado: aquele é o RPC nativo da linguagem (os dois lados compartilham T em compilação), e HTTP parte da premissa oposta, falar com o mundo-não-seu, cujo contrato é o formato-de-fio (método, headers, status, bytes), agnóstico de linguagem. Não há T pra pôr no channel, então HTTP mora nos bytes. A superfície da aplicação é agnóstica de versão (Request/Response/Headers/ Body idênticos em /1.1, /2, /3); a versão vive na conexão, negociada por ALPN. Este pacote entrega os primitivos (tipos + cliente + transporte de servidor + a forma de handler fn(Request) -> Response); router, middleware e baterias de aplicação ficam na lib oficial web (tier 2), por cima desta.

A parte que não muda entre /1.1, /2 e /3. Método, status, headers, corpo, URL, e os dois envelopes (Request/Response).

decl Method { Get; Head; Post; Put; Patch; Delete; Connect; Options; Trace }
fn (m: Method) to_string() -> string // Display ("GET", "POST", …)
fn (Method) parse(s: string) -> Optional[Method] // none se desconhecido
decl Status { pub code: u16 } // 100..599
fn (Status) of(code: u16) -> Status
fn (s: Status) reason() -> string // texto canônico ("Not Found")
fn (s: Status) is_informational() -> bool // 1xx
fn (s: Status) is_success() -> bool // 2xx
fn (s: Status) is_redirect() -> bool // 3xx
fn (s: Status) is_client_error() -> bool // 4xx
fn (s: Status) is_server_error() -> bool // 5xx
fn (s: Status) to_string() -> string // Display ("404 Not Found")

Constantes nomeadas dos status comuns (evita números mágicos no call site):

const ok: Status // 200
const created: Status // 201
const accepted: Status // 202
const no_content: Status // 204
const moved_permanently: Status // 301
const found: Status // 302
const see_other: Status // 303
const not_modified: Status // 304
const temporary_redirect: Status // 307
const permanent_redirect: Status // 308
const bad_request: Status // 400
const unauthorized: Status // 401
const forbidden: Status // 403
const not_found: Status // 404
const method_not_allowed: Status // 405
const conflict: Status // 409
const too_many_requests: Status // 429
const internal_error: Status // 500
const not_implemented: Status // 501
const bad_gateway: Status // 502
const service_unavailable: Status // 503

Map[string,string] não serve: header HTTP tem nome case-insensitive e multi-valor (vários Set-Cookie, vários Accept). Daí o tipo dedicado:

decl Headers { ... }
fn (Headers) new() -> Headers
fn (h: *Headers) set(name: string, value: string) // substitui TODOS os valores do nome
fn (h: *Headers) add(name: string, value: string) // acrescenta (preserva os anteriores)
fn (h: *Headers) remove(name: string)
fn (h: Headers) get(name: string) -> Optional[string] // 1º valor (lookup case-insensitive)
fn (h: Headers) get_all(name: string) -> List[string] // todos os valores do nome
fn (h: Headers) has(name: string) -> bool
fn (h: Headers) names() -> Iterator[string] // nomes presentes (forma canônica)
fn (h: Headers) iter() -> Iterator[(string, string)] // cada par (nome, valor)
fn (h: Headers) len() -> usize

Lookup é case-insensitive (h.get("content-type") acha Content-Type); a saída no fio usa a capitalização canônica do header.

URL parseada (o cliente é o consumidor; se outro domínio precisar, promove-se pra net depois):

decl Url {
pub scheme: string // "http" / "https"
pub host: string
pub port: Optional[u16] // none → default do scheme (80/443)
pub path: string // "/" quando vazio
pub query: Query
pub fragment: Optional[string]
}
fn (Url) parse(s: string) -> Result[Url, error{Malformed}]
fn (u: Url) to_string() -> string // Display (re-encoda percent-encoding)
fn (u: Url) with_path(path: string) -> Url
fn (u: Url) with_query(q: Query) -> Url
decl Query { ... } // multi-valor e ORDENADO (a ordem do fio importa)
fn (Query) new() -> Query
fn (Query) parse(s: string) -> Query // "a=1&b=2" → Query (decoda percent-encoding)
fn (q: *Query) add(key: string, value: string)
fn (q: *Query) set(key: string, value: string)
fn (q: Query) get(key: string) -> Optional[string]
fn (q: Query) get_all(key: string) -> List[string]
fn (q: Query) iter() -> Iterator[(string, string)]
fn (q: Query) to_string() -> string // "a=1&b=2" (encoda)

O corpo satisfaz Readable + Closeable (io): o default é fluxo (não carrega tudo na memória, o que serve download grande e SSE), com conveniências eager por cima.

decl Body { ... } // Readable + Closeable
fn (Body) empty() -> Body
fn (Body) from_bytes(data: []byte) -> Body // corpo conhecido (Content-Length)
fn (Body) from_string(s: string) -> Body
fn (Body) from_reader(r: Readable) -> Body // fluxo de tamanho desconhecido → chunked
fn (b: *Body) read(buf: mut []byte) -> Result[usize, error{Io, Closed}] // Readable (Ok(0)=fim)
fn (b: *Body) close() -> error{Io} // Closeable
// conveniências (consomem o corpo; alocam pelo @mm do contexto):
fn (b: *Body) read_all() -> Result[[]byte, error{Io, Closed}]
fn (b: *Body) to_string() -> Result[string, error{Io, Closed, InvalidUtf8}] // valida UTF-8 na borda
fn (b: Body) len_hint() -> Optional[usize] // Content-Length se conhecido; none se chunked
decl Request {
pub method: Method
pub url: Url
pub headers: Headers
pub body: Body
pub version: Version // no servidor, a versão negociada; no cliente, o transporte decide
}
fn (Request) build(method: Method, url: Url) -> Request // forma explícita
fn (Request) get(url: string) -> Result[Request, error{Malformed}] // conveniências (parseiam a URL)
fn (Request) post(url: string, body: Body) -> Result[Request, error{Malformed}]
fn (r: *Request) with_header(name: string, value: string) -> Request // encadeável (devolve ajustado)
fn (r: *Request) with_body(body: Body) -> Request
decl Response {
pub status: Status
pub headers: Headers
pub body: Body
pub version: Version
}
fn (Response) of(status: Status) -> Response // resposta vazia com status
fn (Response) text(status: Status, s: string) -> Response // body de texto + Content-Type text/plain
fn (Response) bytes(status: Status, data: []byte, content_type: string) -> Response
fn (Response) stream(status: Status, r: Readable, content_type: string) -> Response // corpo em fluxo
fn (r: *Response) with_header(name: string, value: string) -> Response
fn (r: *Response) with_body(body: Body) -> Response
decl Version { Http11; Http2; Http3 }
fn (v: Version) to_string() -> string // "HTTP/1.1" / "HTTP/2" / "HTTP/3"

Full por design (pool, redirect, cookie jar, descompressão), porque um cliente HTTP incompleto não serve a sistemas reais. Tudo acontece dentro de send; o Client carrega o estado (pool de conexões, cookie jar).

decl Client { ... }
fn (Client) new() -> Client // defaults sensatos (pool/redirect/cookies/gzip on)
fn (Client) with(cfg: ClientConfig) -> Client
fn (c: *Client) send(req: Request) -> Result[Response, HttpError] // o método central
fn (c: *Client) get(url: string) -> Result[Response, HttpError] // conveniências
fn (c: *Client) post(url: string, body: Body) -> Result[Response, HttpError]
fn (c: *Client) put(url: string, body: Body) -> Result[Response, HttpError]
fn (c: *Client) delete(url: string) -> Result[Response, HttpError]

Configuração e o error-set fechado do cliente:

decl ClientConfig {
pub timeout: Duration // por requisição, ponta a ponta (default 30s)
pub max_redirects: u32 // 0 = não seguir (default 10)
pub pool: PoolConfig
pub cookies: bool // cookie jar automático (default true)
pub auto_decompress: bool // gzip/deflate transparente na resposta (default true)
pub tls: TlsConfig // de net: verify, sni, alpn, ca_bundle
pub default_headers: Headers // mesclados em toda requisição (User-Agent, Accept, …)
pub https_redirect_only: bool // recusa redirect que rebaixa https→http (default true)
}
decl PoolConfig {
pub max_idle_per_host: u32 // conexões ociosas reusáveis por host (default 8)
pub max_per_host: u32 // teto de conexões simultâneas por host (0 = ilimitado)
pub idle_timeout: Duration // fecha a ociosa após (default 90s)
}
alias HttpError = error{
Malformed, // URL ou resposta malformada
Dns, // resolução de nome falhou
Connect, // não conectou (recusado / inalcançável)
Tls, // handshake / certificado
Timeout, // estourou o timeout da requisição
TooManyRedirects, // excedeu max_redirects
Closed, // a conexão fechou no meio
Io
}
use http
client := http.Client.new()
resp := client.get("https://api.exemplo.com/users") catch |e| { return e }
if resp.status.is_success() {
body := resp.body.to_string() catch |e| { return e }
process(body)
}

Download grande sem carregar tudo na memória: o corpo é Readable, então io.copy o bombeia.

resp := client.get("https://exemplo.com/grande.bin") catch |e| { return e }
defer resp.body.close() // devolve a conexão ao pool
out := fs.create("grande.bin") catch |e| { return e }
io.copy(out, resp.body) catch |e| { return e } // fluxo → arquivo, em blocos

Ciclo da conexão e do pool: send reusa uma conexão ociosa do pool para o mesmo (scheme, host, port) ou abre uma nova; a conexão só volta ao pool quando o corpo da resposta é drenado ou fechado, por isso leia o corpo até o fim (read_all/to_string) ou defer resp.body.close(). Redirects são seguidos até max_redirects, com a semântica do HTTP (303 vira GET; 307/308 preservam método e corpo); um corpo de requisição em fluxo não é replayable, então um redirect que exija reenviá-lo vira Err (use corpo eager, from_bytes/from_string, quando precisar sobreviver a redirect). Cookie jar e descompressão são automáticos quando ligados.

Cookie jar (automático; exposto para inspeção/controle):

fn (c: *Client) jar() -> *CookieJar
decl CookieJar { ... }
fn (j: *CookieJar) add(url: Url, c: Cookie)
fn (j: *CookieJar) cookies_for(url: Url) -> List[Cookie]
fn (j: *CookieJar) clear()
decl Cookie {
pub name: string
pub value: string
pub domain: string
pub path: string
pub expires: Optional[DateTime] // de `time`; none = sessão
pub secure: bool
pub http_only: bool
}

O handler é só uma função fn(Request) -> Response, sem tipo novo:

alias Handler = fn(Request) -> Response
fn serve(addr: string, handler: Handler) -> error{AddrInUse, PermissionDenied, Io} // sobe e bloqueia
fn serve_with(cfg: ServerConfig, handler: Handler) -> error{AddrInUse, PermissionDenied, Io}
// forma controlável (shutdown gracioso):
fn bind(cfg: ServerConfig, handler: Handler) -> Result[Server, error{AddrInUse, PermissionDenied}]
decl Server { pub addr: Address }
fn (s: *Server) run() -> error{Io} // serve até shutdown (suspende este processo)
fn (s: *Server) shutdown(grace: Duration) -> error{Io} // para de aceitar; drena em 'grace'; fecha
decl ServerConfig {
pub addr: string
pub tls: Optional[TlsConfig] // none = texto puro (http/1.1, h2c); some = HTTPS (h2/h3 via ALPN)
pub versions: List[Version] // o que oferecer (default: tudo que o transporte suporta)
pub read_timeout: Duration // por requisição
pub write_timeout: Duration
pub max_header_bytes: usize // teto de headers (anti-abuso)
pub max_body_bytes: Optional[usize] // teto de corpo (none = sem teto)
}

O modelo é o de atores (§3/§8) estendido à HTTP. Em /1.1, cada conexão é um stream serial, e o servidor faz spawn de um processo rodando handler(req). Em /2 e /3, a conexão é multiplexada: um processo dono demultiplexa os frames (HPACK/QPACK, controle de fluxo, estado de stream) e faz spawn de um processo por stream. Em qualquer versão, o handler roda no seu próprio processo: um panic numa requisição crasha só aquele processo (o supervisor observa pelo catch, §8), nunca o servidor, e o cliente recebe 500. A resposta pode ter corpo em fluxo (Response.stream) para SSE e streaming, sem materializar tudo.

use http
fn handle(req: Request) -> Response {
match req.method {
Get => Response.text(http.ok, "olá, {{req.url.path}}")
_ => Response.of(http.method_not_allowed)
}
}
fn main() {
http.serve("0.0.0.0:8080", handle) catch |e| {
io.eprintln("falha ao subir: {{e}}")
os.exit(1)
}
}

Shutdown gracioso casa com os sinais como channel (os.signals, §4): rode o servidor num processo e peça shutdown quando o sinal chegar.

srv := http.bind(cfg, handle) catch |e| { os.exit(1) }
spawn srv.run()
sigs := os.signals(.Interrupt, .Terminate)
loop s in sigs {
match s {
Interrupt | Terminate => { srv.shutdown(10s); break }
_ => {}
}
}

A superfície acima é idêntica nas três versões; a diferença mora aqui, e cada versão assenta no net (camada 2) de um jeito. A versão é negociada, não escolhida por chamada.

  • HTTP/1.1, sobre TcpStream (ou TlsStream), §net. Uma requisição/resposta por vez na conexão; keep-alive reusa a conexão em série; corpo de tamanho desconhecido vai chunked. O pool guarda conexões ociosas (muitas conexões por host para concorrência). É o corte completo do primeiro release.
  • HTTP/2, sobre um único TlsStream (ALPN negocia "h2") ou texto puro h2c. Uma conexão multiplexa muitos streams: o processo-dono roda a camada de frames (HPACK, controle de fluxo) e faz spawn por stream. Isso refina o modelo de atores, processo-por-stream em vez de processo-por-conexão, sem quebrá-lo; e o pool passa a manter uma conexão por host com muitos streams concorrentes (em vez das muitas conexões do /1.1).
  • HTTP/3, sobre QUIC (UDP, via udp_bind/datagramas do net), com TLS 1.3 embutido no transporte, multiplex próprio (sem head-of-line blocking de TCP) e 0-RTT. ALPN "h3" + Alt-Svc anuncia a disponibilidade. A superfície da aplicação não muda; o que é grande é a implementação: uma stack QUIC (controle de congestão, recuperação de perda, multiplex sobre UDP) é um subsistema inteiro, entregue conforme a lib amadurece. O compromisso está fechado pela API já agnóstica de versão; o peso é a stack.

Como a versão é decidida. No cliente, o ALPN durante o TLS negocia a maior versão mutuamente suportada (h2/h3 se oferecidas, senão http/1.1); h3 adicionalmente depende de descoberta por Alt-Svc ou opt-in. No servidor, ServerConfig.versions lista o que se oferece, e o ALPN seleciona por conexão. Não há botão de versão por requisição: a superfície é agnóstica, a conexão resolve. (Forçar uma versão específica, p/ teste, é direção futura via ClientConfig.)


  • Tier 1, sobre a camada 2 do net. HTTP fala com o mundo-não-seu via bytes + TLS, não pelo Channel[T] tipado. O channel tipado é o RPC nativo (ambos os lados compartilham T); HTTP é agnóstico de linguagem, não há T. É diferença de categoria, não limitação, e por isso a lib parseia o protocolo ela mesma sobre TcpStream/TlsStream/QUIC.
  • Superfície agnóstica de versão (como o net/http do Go): Request/Response/Headers/Body idênticos em /1.1, /2, /3; a versão vive na conexão, negociada por ALPN. Sem versão por chamada.
  • Processo por stream, não por conexão. Modelo de atores (§3/§8) estendido: /1.1 usa processo por conexão; /2-3 usa processo por stream multiplexado. panic numa requisição crasha só o processo dela; o servidor segue. É a tolerância a falhas da linguagem aplicada a HTTP, de graça.
  • Framework é o web (tier 2), separado. http é primitivo (tipos + cliente + transporte de servidor + a forma fn(Request) -> Response); router, middleware e baterias de app sobem no web, e a UI (§20) sobe no web. Quem importa http quer construir; quem quer baterias importa web. Foi por isso que HTTP ficou não-core: ela é, ela própria, uma pilha em camadas.
  • Cliente full (pool/redirect/cookie/descompressão) porque mira serviços/sistemas, onde cliente incompleto é inútil. Tudo dentro de Client.send; o estado (pool, jar) mora no Client.
  • Corpo streaming por default: Body satisfaz Readable+Closeable; read_all/to_string são conveniências. Download grande e SSE sem materializar tudo.
  • Async colorless (§12): nenhuma função é async; o processo suspende no IO; paralelismo é spawn por stream. Sem Future/await/context.Context; cancelamento é timeout ou morte de processo (§8).
  • /3 comprometido, implementação adiada. A superfície já agnóstica deixa QUIC entrar sem trauma; a stack QUIC é o peso real, entregue conforme a lib amadurece.
  • Descompressão usa codecs de encoding. auto_decompress apoia-se em DEFLATE/gzip (formatos frozen, candidatos a encoding/compress conforme brotli/zstd entram); http só os orquestra, o mesmo papel que o net/TLS faz com crypto. A lib não reimplementa compressão.
  • Sem allocator nas assinaturas (colorless); buffers de streaming alocam pelo @mm do contexto, ou você passa mut []byte onde quer controle (a porta read(buf) do Readable).
  • Url mora aqui (o consumidor é o cliente); promove-se pra net se outro domínio passar a precisar.