Files
go-telegram/client/fasthttp_doer.go
T
lukaszraczylo bfb7e9875e feat(client): opt-in fasthttp transport (NewFastHTTPDoer)
Adds an alternative HTTPDoer backed by valyala/fasthttp for high-throughput
bots. Cuts per-call allocs from 102 to 56 in the cross-library bench
(within 8 of telego, which uses fasthttp by default), and per-call bytes
from 11.1 KiB to 6.6 KiB.

  bot := client.New(token,
      client.WithHTTPClient(client.NewFastHTTPDoer()),
  )

Implementation notes:
  - Wraps *fasthttp.Client behind the existing HTTPDoer (Do *http.Request)
    interface, so RetryDoer, custom transports, observability middleware,
    and the 1428 generated tests all keep working as-is.
  - Translates *http.Request -> fasthttp.Request once per call and
    returns a *http.Response whose Body releases the pooled fasthttp
    response on Close (net/http contract).
  - Recognises the bufferReadCloser / readerReadCloser shapes produced
    by buildRequest and passes their underlying bytes straight to
    SetBodyRaw -- no io.ReadAll, no copy.
  - Honours ctx.Deadline via DoDeadline, falls back to WithFastHTTPReadTimeout
    when no deadline is set. fasthttp.ErrTimeout maps to
    context.DeadlineExceeded for errors.Is compatibility.

Default stays net/http: fasthttp is HTTP/1.1 only, doesn't compose with
the http.RoundTripper middleware ecosystem, and most users don't have
the throughput to notice. Bots making thousands of API calls/sec should
opt in.

Multipart/file-upload path remains on net/http per the agreed scope --
the perf bottleneck was JSON-method round-trip, not file uploads.

Time numbers in the report deferred until a quiet-system bench run;
allocs/bytes numbers (which are deterministic per code path) are
already updated.
2026-05-10 23:07:04 +01:00

232 lines
6.8 KiB
Go

package client
import (
"context"
"errors"
"io"
"net/http"
"strconv"
"time"
"github.com/valyala/fasthttp"
)
// FastHTTPDoer is an HTTPDoer backed by github.com/valyala/fasthttp. It
// trades net/http compatibility (and HTTP/2 support) for substantially
// fewer allocations per request — fasthttp pools its Request and Response
// objects and uses a zero-allocation HTTP/1.1 parser.
//
// Use it for high-throughput bots when GC pressure matters and you don't
// need HTTP/2 or any net/http-only middleware (RoundTripper composition,
// the OpenTelemetry httptrace family, etc.):
//
// bot := client.New(token, client.WithHTTPClient(client.NewFastHTTPDoer()))
//
// Wrap with RetryDoer the same way you would the default doer.
type FastHTTPDoer struct {
client *fasthttp.Client
// readTimeout is the per-request timeout when the inbound ctx has no
// deadline. Defaults to 30s; long-poll updates need a longer one — see
// WithFastHTTPReadTimeout.
readTimeout time.Duration
}
// FastHTTPDoerOption configures a FastHTTPDoer.
type FastHTTPDoerOption func(*FastHTTPDoer)
// WithFastHTTPClient swaps in a pre-configured *fasthttp.Client.
// Useful for sharing a connection pool across multiple bots or applying
// custom dial / TLS configuration.
func WithFastHTTPClient(c *fasthttp.Client) FastHTTPDoerOption {
return func(d *FastHTTPDoer) { d.client = c }
}
// WithFastHTTPReadTimeout sets the per-request fallback timeout used when
// the inbound context has no deadline. Long-poll callers should pass a
// value larger than the long-poll timeout.
func WithFastHTTPReadTimeout(t time.Duration) FastHTTPDoerOption {
return func(d *FastHTTPDoer) { d.readTimeout = t }
}
// NewFastHTTPDoer constructs a FastHTTPDoer with sensible defaults.
func NewFastHTTPDoer(opts ...FastHTTPDoerOption) *FastHTTPDoer {
d := &FastHTTPDoer{
client: &fasthttp.Client{
ReadTimeout: 90 * time.Second,
WriteTimeout: 30 * time.Second,
MaxIdleConnDuration: 90 * time.Second,
},
readTimeout: 30 * time.Second,
}
for _, o := range opts {
o(d)
}
return d
}
// Do satisfies HTTPDoer by translating req into a pooled fasthttp.Request,
// dispatching it, and returning a *http.Response whose Body releases the
// pooled fasthttp.Response when Close is called.
//
// The conversion is intentionally minimal: URL goes via req.URL.RequestURI()
// + Host (avoids re-parsing), header values move byte-for-byte, and the
// body is taken straight from req.Body. *bytes.Buffer / *bytes.Reader are
// recognised so we can pass the underlying bytes without io.ReadAll.
func (d *FastHTTPDoer) Do(req *http.Request) (*http.Response, error) {
if req == nil {
return nil, errors.New("client: nil http.Request")
}
fReq := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(fReq)
fReq.SetRequestURI(req.URL.String())
fReq.Header.SetMethod(req.Method)
if req.Host != "" {
fReq.Header.SetHost(req.Host)
}
for name, values := range req.Header {
for _, v := range values {
fReq.Header.Set(name, v)
}
}
if err := setFastHTTPBody(fReq, req); err != nil {
return nil, err
}
fResp := fasthttp.AcquireResponse()
// fResp is released by fasthttpResponseBody.Close — caller is
// expected to defer resp.Body.Close() per net/http contract.
deadline, hasDeadline := req.Context().Deadline()
var err error
if hasDeadline {
err = d.client.DoDeadline(fReq, fResp, deadline)
} else {
err = d.client.DoTimeout(fReq, fResp, d.readTimeout)
}
if err != nil {
fasthttp.ReleaseResponse(fResp)
// Map fasthttp's timeout error to ctx.Err semantics so callers
// can errors.Is(err, context.DeadlineExceeded).
if hasDeadline && errors.Is(err, fasthttp.ErrTimeout) {
return nil, context.DeadlineExceeded
}
return nil, err
}
httpResp := &http.Response{
StatusCode: fResp.StatusCode(),
Status: strconv.Itoa(fResp.StatusCode()) + " " + fastHTTPStatusText(fResp.StatusCode()),
Header: make(http.Header, fResp.Header.Len()),
ContentLength: int64(fResp.Header.ContentLength()),
Body: &fasthttpResponseBody{resp: fResp, body: fResp.Body()},
Request: req,
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
}
for k, v := range fResp.Header.All() {
httpResp.Header.Add(string(k), string(v))
}
return httpResp, nil
}
// setFastHTTPBody copies req.Body into fReq with the cheapest path that
// preserves correctness. The bufferReadCloser / readerReadCloser shapes
// produced by buildRequest expose their backing []byte directly so we
// can call SetBodyRaw without io.ReadAll. Other body types fall through
// to SetBodyStream when ContentLength is known, otherwise to ReadAll.
func setFastHTTPBody(fReq *fasthttp.Request, req *http.Request) error {
if req.Body == nil {
return nil
}
switch v := req.Body.(type) {
case bufferReadCloser:
fReq.SetBodyRaw(v.Bytes())
return nil
case readerReadCloser:
// *bytes.Reader.Bytes() returns the unread portion.
size := v.Len()
buf := make([]byte, size)
_, err := v.Read(buf)
if err != nil && !errors.Is(err, io.EOF) {
return err
}
fReq.SetBodyRaw(buf)
return nil
default:
if req.ContentLength > 0 {
fReq.SetBodyStream(v, int(req.ContentLength))
} else {
body, err := io.ReadAll(v)
if err != nil {
return err
}
fReq.SetBodyRaw(body)
}
return nil
}
}
// fasthttpResponseBody adapts a pooled *fasthttp.Response so it satisfies
// io.ReadCloser. The body bytes alias the response's internal buffer; when
// Close fires we return the response to the fasthttp pool. Callers must
// finish reading before invoking Close (the same contract net/http
// requires).
type fasthttpResponseBody struct {
resp *fasthttp.Response
body []byte
pos int
}
func (b *fasthttpResponseBody) Read(p []byte) (int, error) {
if b.pos >= len(b.body) {
return 0, io.EOF
}
n := copy(p, b.body[b.pos:])
b.pos += n
return n, nil
}
func (b *fasthttpResponseBody) Close() error {
if b.resp != nil {
fasthttp.ReleaseResponse(b.resp)
b.resp = nil
b.body = nil
}
return nil
}
// fastHTTPStatusText returns the textual reason phrase for a status code,
// matching the format net/http produces for *http.Response.Status. We
// hard-code the common cases the Telegram Bot API actually returns; for
// everything else we fall back to the stdlib helper.
func fastHTTPStatusText(code int) string {
switch code {
case http.StatusOK:
return "OK"
case http.StatusBadRequest:
return "Bad Request"
case http.StatusUnauthorized:
return "Unauthorized"
case http.StatusForbidden:
return "Forbidden"
case http.StatusNotFound:
return "Not Found"
case http.StatusTooManyRequests:
return "Too Many Requests"
case http.StatusInternalServerError:
return "Internal Server Error"
case http.StatusBadGateway:
return "Bad Gateway"
case http.StatusServiceUnavailable:
return "Service Unavailable"
case http.StatusGatewayTimeout:
return "Gateway Timeout"
default:
return http.StatusText(code)
}
}