HTTP/2#
El cliente HTTP/2 implementa RFC 7540 y HPACK (RFC 7541) desde cero — sin net/http, sin golang.org/x/net/http2. Tres constructores cubren las topologías habituales: una sola conexión, un pool por host, o un conjunto de backends gobernado por un resolver. Los tres devuelven un *client.Client con la misma API Do / DoStream.
Conexión única#
client.NewSingleConnClient mantiene una conexión y vuelve a marcar automáticamente cuando se cae. Este es examples/http2/main.go:
// Command http2-example issues a single HTTP/2 GET with the poseidon client.
// TransportSingleConn (the default) negotiates h2 over TLS and reuses one
// connection with automatic redial.
//
// go run ./examples/http2
package main
import (
"context"
"crypto/tls"
"fmt"
"log"
"time"
"github.com/lodgvideon/poseidon-http-client/client"
"github.com/lodgvideon/poseidon-http-client/conn"
)
func main() {
c, err := client.NewSingleConnClient(
"www.cloudflare.com:443",
&conn.TLSDialer{Config: &tls.Config{
ServerName: "www.cloudflare.com",
NextProtos: []string{"h2"},
}},
)
if err != nil {
log.Fatalf("build client: %v", err)
}
defer func() { _ = c.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Response is caller-owned and reusable; Reset() clears it between requests.
resp := &client.Response{}
if err := c.Do(ctx, client.GET("/"), resp); err != nil {
log.Fatalf("GET /: %v", err)
}
fmt.Printf("HTTP/2 %d — %d bytes\n", resp.Status, len(resp.Body))
}Response pertenece al llamador: asígnalo una vez y llama a resp.Reset() entre peticiones. client.GET(path) es una forma abreviada; construye un client.Request{Method, Scheme, Authority, Path, BodyMode} directamente para tener control total.
Pool de conexiones#
client.NewPoolClient mantiene un pool por host. Asigna cada petición a la conexión menos cargada, marca hasta MaxConnsPerHost conexiones y desaloja las inactivas en cada tick del health check.
c, err := client.NewPoolClient("api.example.com:443", dialer,
client.PoolOptions{
MaxConnsPerHost: 4,
MaxStreamsPerConn: 100,
HealthCheckPeriod: 30 * time.Second,
})MaxStreamsPerConn es un tope blando; el límite efectivo es el mínimo entre este valor y el SETTINGS_MAX_CONCURRENT_STREAMS del peer. c.Warmup(n) establece conexiones por adelantado; c.PoolStats() informa los conteos en vivo; c.Shutdown(timeout) drena de forma ordenada.
Streaming#
DoStream retorna en cuanto llegan los HEADERS de la respuesta. El llamador bombea Recv para recibir DATA, trailers y eventos de reset, y debe llamar a Close.
var sr client.StreamResponse
if err := c.DoStream(ctx, client.GET("/events"), &sr); err != nil {
log.Fatal(err)
}
defer func() { _ = sr.Close() }() // mandatory: releases the stream slot
for {
ev, err := sr.Recv(ctx)
if errors.Is(err, client.ErrStreamEnded) {
break
}
if err != nil {
log.Fatal(err)
}
if ev.Type == client.EventData {
// ev.Data aliases a pooled buffer recycled on the next Recv;
// use ev.DataCopy() to retain the bytes.
fmt.Printf("chunk: %d bytes\n", len(ev.Data))
}
}Formas relacionadas:
c.Stream(ctx, req, fn)— forma con callback que siempre cierra el stream por ti.req.BodyMode = client.BodyStreamcon unDonormal — el cuerpo de la respuesta llega de forma perezosa enresp.BodyReader(unio.ReadCloser; ciérralo).req.BodyReader— envía el cuerpo de la petición desde unio.Reader, troceado en frames DATA bajo control de flujo.sr.WaitTrailers(ctx)— drena el cuerpo y devuelve los trailers de la respuesta (p. ej. ungrpc-statusde gRPC).
Descubrimiento de servicios#
client.NewManagedClient recibe un Resolver (qué backends existen) y un Selector (cuál atiende la siguiente petición). Un sub-pool por cada dirección resuelta.
resolver := client.StaticResolver(
client.Address{Host: "10.0.0.1", Port: 443},
client.Address{Host: "10.0.0.2", Port: 443},
)
c, err := client.NewManagedClient(resolver, dialer,
client.WithSelector(client.RoundRobin()))client.DNSResolver(host, port, client.DNSOptions{TTL: 30 * time.Second}) vuelve a resolver los registros A/AAAA según un TTL; el pool marca los backends nuevos y drena los eliminados. Selectores: RoundRobin(), Random(rng), Hash(keyFn) para afinidad de sesión. Resolver es una interfaz — implementa Resolve/Watch para conectar tu propio mecanismo de descubrimiento.
Resiliencia#
Los reintentos son opt-in vía Retryer. Reemite peticiones idempotentes ante fallos transitorios (REFUSED_STREAM, GOAWAY, errores de conexión) con backoff exponencial y jitter, acotado por MaxAttempts.
r := c.Retryer(client.RetryOptions{
MaxAttempts: 5,
IsRetryable: func(err error, resp *client.Response) bool {
return err == nil && resp != nil && resp.Status == 503
},
})
var resp client.Response
err = r.Do(ctx, client.GET("/v1/health"), &resp)Los métodos no idempotentes no se reintentan salvo que establezcas req.Idempotency = client.ForceIdempotent (acompáñalo de una clave de idempotencia). El rate limiting por token bucket es una opción del constructor — client.WithRateLimit(100, 20) limita a 100 peticiones/s con ráfagas de hasta 20. Deadlines por petición: establece req.Timeout; al expirar, Do falla con context.DeadlineExceeded y el stream se resetea con RST_STREAM(CANCEL).
Observabilidad#
WithHooks instala callbacks de ciclo de vida; MetricsSnapshot devuelve una vista congelada de contadores e histogramas de latencia.
hooks := &client.Hooks{
OnRequestComplete: func(ev client.RequestCompleteEvent) {
log.Printf("%s %s -> %d in %s (%d B sent, %d B recv)",
ev.Method, ev.Path, ev.Status, ev.Latency,
ev.BytesSent, ev.BytesRecv)
},
}
c, err := client.NewSingleConnClient(addr, dialer, client.WithHooks(hooks))
// ...
snap := c.MetricsSnapshot()
fmt.Println(snap.Counters.Responses2xx, snap.Latency.Request.Quantile(0.99))Otros hooks: OnRequestStart, OnRetry, OnDial, OnConnClose, OnResolverUpdate. Los hooks no deben bloquear. c.PoolStats() expone el estado en vivo del pool, útil para un endpoint /debug.
Funciones avanzadas del protocolo#
Server push (RFC 7540 §8.2). Registra un handler con WithPushHandler; esto habilita el push en SETTINGS. El cliente drena cada stream empujado en un Response y lo entrega al callback. Sin handler, PUSH_PROMISE es un error de protocolo.
push := func(ctx context.Context, promised []conn.HeaderField, resp *client.Response, err error) {
if err != nil {
log.Printf("push failed: %v", err)
return
}
log.Printf("pushed -> %d (%d bytes)", resp.Status, len(resp.Body))
}
c, err := client.NewSingleConnClient(addr, dialer, client.WithPushHandler(push))CONNECT extendido (RFC 8441). Tuneliza un WebSocket (o cualquier protocolo) sobre un único stream HTTP/2. El servidor debe anunciar SETTINGS_ENABLE_CONNECT_PROTOCOL=1.
req := &client.Request{
Method: "CONNECT",
Protocol: "websocket",
Path: "/chat",
BodyMode: client.BodyStream,
}
var sr client.StreamResponse
err = c.DoStream(ctx, req, &sr)H2C (prior knowledge). HTTP/2 en claro, sin TLS ni ALPN: usa un PlaintextDialer y fija el scheme por defecto en http.
c, err := client.NewSingleConnClient("localhost:8080",
&conn.PlaintextDialer{},
client.WithDefaultScheme("http"))También soportado: prioridad de peticiones (req.Priority = &frame.Priority{...}, RFC 7540 §5.3), trailers en la petición (req.TrailerFunc) y dialers de proxy HTTP CONNECT (conn.ProxyTLSDialer).
Ejemplo de generador de carga#
examples/loadgen es un generador de carga HTTP/2 completo en un solo archivo: un cliente con pool, N goroutines worker, un tope global de QPS opcional y un resumen derivado de MetricsSnapshot.
go run ./examples/loadgen -url https://localhost:8443/ \
-conns 4 -workers 64 -duration 30s -rps 5000