From bfb7e9875eb9d4db06f936cb8c123220dea2b227 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sun, 10 May 2026 23:07:04 +0100 Subject: [PATCH] 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. --- README.md | 5 +- client/call.go | 26 ++- client/fasthttp_doer.go | 231 +++++++++++++++++++++++ client/fasthttp_doer_test.go | 94 +++++++++ docs/benchmarks/2026-05-10-comparison.md | 13 +- docs/reference/client.md | 72 +++++++ go.mod | 4 + go.sum | 8 + test/benchmarks/call_bench_test.go | 27 ++- test/benchmarks/go.mod | 8 +- test/benchmarks/go.sum | 16 +- 11 files changed, 481 insertions(+), 23 deletions(-) create mode 100644 client/fasthttp_doer.go create mode 100644 client/fasthttp_doer_test.go diff --git a/README.md b/README.md index 9d8cd0d..a8f326a 100644 --- a/README.md +++ b/README.md @@ -328,9 +328,12 @@ Apples-to-apples micro-benchmarks against the five most-starred Go Telegram libr |------|---------|--------------| | Webhook decode (small Update) | **ours** — 1.83 µs / 11 allocs | 1st of 6 | | Large Update unmarshal (unions + reply markup) | **ours** — 6.73 µs / 34 allocs | 1st of 6 | -| `sendMessage` round-trip (mock server) | telego — 35.8 µs / 48 allocs | 2nd of 5 | +| `sendMessage` round-trip — `net/http` default | telego — 35.8 µs / 48 allocs | 2nd of 5 (102 allocs) | +| `sendMessage` round-trip — opt-in `fasthttp` | telego — 48 allocs | within 8 of telego (56 allocs) | | Dispatcher routing (20 handlers, last matches) | **ours** — 98 ns / 3 allocs | 1st of 3 | +Opt into fasthttp for high-throughput bots: `client.WithHTTPClient(client.NewFastHTTPDoer())`. Trade-off: HTTP/1.1 only, no `RoundTripper` middleware composition. + Full tables, caveats, and reproduction steps: **[`docs/benchmarks/2026-05-10-comparison.md`](docs/benchmarks/2026-05-10-comparison.md)**. diff --git a/client/call.go b/client/call.go index 39068af..ce8730b 100644 --- a/client/call.go +++ b/client/call.go @@ -215,6 +215,24 @@ func (b *Bot) buildRequest(ctx context.Context, method string, body io.Reader) ( return req.WithContext(ctx), nil } +// bufferReadCloser exposes a *bytes.Buffer as io.ReadCloser without going +// through io.NopCloser. Keeping the concrete *bytes.Buffer accessible lets +// alternative HTTPDoers (e.g. FastHTTPDoer) type-assert and pass the +// underlying bytes through to their native body-set APIs without copying. +type bufferReadCloser struct { + *bytes.Buffer +} + +func (bufferReadCloser) Close() error { return nil } + +// readerReadCloser is the equivalent wrapper for *bytes.Reader (used by +// the Marshal fallback path when the codec doesn't implement BodyEncoder). +type readerReadCloser struct { + *bytes.Reader +} + +func (readerReadCloser) Close() error { return nil } + // bodyToReadCloser wraps body for assignment to *http.Request.Body. The // type switch covers the body shapes encodeJSONBody returns: a pooled // *bytes.Buffer (BodyEncoder path or {} fast path) or a *bytes.Reader @@ -226,16 +244,16 @@ func bodyToReadCloser(body io.Reader) (io.ReadCloser, int64, func() (io.ReadClos case *bytes.Buffer: buf := v.Bytes() length := int64(len(buf)) - return io.NopCloser(v), length, func() (io.ReadCloser, error) { - return io.NopCloser(bytes.NewReader(buf)), nil + return bufferReadCloser{v}, length, func() (io.ReadCloser, error) { + return readerReadCloser{bytes.NewReader(buf)}, nil } case *bytes.Reader: length := int64(v.Len()) // Snapshot the reader's current data so GetBody returns a fresh one. snapshot := *v - return io.NopCloser(v), length, func() (io.ReadCloser, error) { + return readerReadCloser{v}, length, func() (io.ReadCloser, error) { s := snapshot - return io.NopCloser(&s), nil + return readerReadCloser{&s}, nil } default: // Unknown reader: no length, no replay. Should not happen with the diff --git a/client/fasthttp_doer.go b/client/fasthttp_doer.go new file mode 100644 index 0000000..5b8cc8e --- /dev/null +++ b/client/fasthttp_doer.go @@ -0,0 +1,231 @@ +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) + } +} diff --git a/client/fasthttp_doer_test.go b/client/fasthttp_doer_test.go new file mode 100644 index 0000000..0d7ec30 --- /dev/null +++ b/client/fasthttp_doer_test.go @@ -0,0 +1,94 @@ +package client + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestFastHTTPDoer_BasicRoundTrip(t *testing.T) { + got := make(chan struct{ method, ct, body string }, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + got <- struct{ method, ct, body string }{r.Method, r.Header.Get("Content-Type"), string(body)} + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"ok":true,"result":42}`) + })) + defer srv.Close() + + d := NewFastHTTPDoer() + req, err := http.NewRequest(http.MethodPost, srv.URL+"/sendMessage", strings.NewReader(`{"chat_id":1,"text":"hi"}`)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(context.Background()) + + resp, err := d.Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != 200 { + t.Fatalf("status: got %d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if string(body) != `{"ok":true,"result":42}` { + t.Fatalf("body: got %q", body) + } + + rec := <-got + if rec.method != http.MethodPost { + t.Fatalf("method: got %q", rec.method) + } + if rec.ct != "application/json" { + t.Fatalf("content-type: got %q", rec.ct) + } + if rec.body != `{"chat_id":1,"text":"hi"}` { + t.Fatalf("body: got %q", rec.body) + } +} + +func TestFastHTTPDoer_HonoursContextDeadline(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(200 * time.Millisecond) + _, _ = io.WriteString(w, "ok") + })) + defer srv.Close() + + d := NewFastHTTPDoer(WithFastHTTPReadTimeout(time.Hour)) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req = req.WithContext(ctx) + + _, err := d.Do(req) + if err == nil { + t.Fatal("expected timeout error, got nil") + } +} + +func TestFastHTTPDoer_IntegratesWithBot(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"ok":true,"result":{"message_id":7,"date":0,"text":"hi"}}`) + })) + defer srv.Close() + + bot := New("123:abc", + WithBaseURL(srv.URL), + WithHTTPClient(NewFastHTTPDoer()), + ) + req := &benchSendReq{ChatID: 1, Text: "hi"} + got, err := Call[*benchSendReq, benchMsgResp](context.Background(), bot, "sendMessage", req) + if err != nil { + t.Fatal(err) + } + if got.MessageID != 7 || got.Text != "hi" { + t.Fatalf("got %+v", got) + } +} diff --git a/docs/benchmarks/2026-05-10-comparison.md b/docs/benchmarks/2026-05-10-comparison.md index 331975a..cb084ee 100644 --- a/docs/benchmarks/2026-05-10-comparison.md +++ b/docs/benchmarks/2026-05-10-comparison.md @@ -20,7 +20,7 @@ - **Webhook decode** (small Update): ours is **12–20% faster** than every competitor and ties telego for the lowest alloc count (11). - **Large Update unmarshal** (entities + reply markup + photo array): ours is **17–34% faster** with the lowest ns/op of all six. telego edges us on alloc count (31 vs 34) at the cost of ~17% more time. -- **API call round-trip** (mock HTTP server): telego wins (35.8 µs / 48 allocs) thanks to its `application/x-www-form-urlencoded` shortcut on simple methods; ours is **second** (39.8 µs / 102 allocs) and beats gotba, telebot, gobot. +- **API call round-trip** (mock HTTP server): telego wins on allocs (35.8 µs / 48 allocs) because it uses fasthttp by default. We default to `net/http` (102 allocs / 39.8 µs); with the opt-in `client.NewFastHTTPDoer` we drop to 56 allocs / 6.6 KiB — within 8 of telego while keeping `*http.Request` semantics (RetryDoer, middleware, generated tests). - **Dispatcher routing** (20 handlers, last matches): ours is **2.5–2.8× faster than telebot and gobot** (98 ns vs 271 / 246 ns). ## How to read these numbers @@ -67,16 +67,19 @@ Build params → POST to local `httptest.Server` returning `{"ok":true,"result": | Lib | sec/op | B/op | allocs/op | |-----|--------|------|-----------| -| ours | 39.83 µs ±4% | 11.09 KiB | 102 | +| ours (default `net/http`) | 39.83 µs ±4% | 11.09 KiB | 102 | +| ours (opt-in `fasthttp`) | *time TBD on quiet box* | **6.62 KiB** | **56** | | gotba | 42.03 µs ±4% | 10.97 KiB | 125 | | telebot | 43.41 µs ±1% | 13.15 KiB | 139 | | gobot | 61.19 µs ±1% | 13.50 KiB | 176 | -| **telego** | **35.84 µs ±1%** | **6.547 KiB** | **48** | +| **telego** (uses fasthttp) | **35.84 µs ±1%** | **6.547 KiB** | **48** | | echotron | *skipped — see below* | — | — | **Notes.** -- telego wins by sending requests as `application/x-www-form-urlencoded` form data (cheaper than JSON marshal+upload for small payloads), plus an aggressive request-pool. We send JSON over `multipart/form-data` only when needed; for the JSON case our cost lands between gotba and telego. -- Our request path runs through a manually-constructed `*http.Request` with a pre-parsed base URL (cached on `*Bot`), and request bodies are stream-encoded into a pooled `*bytes.Buffer` via the optional `BodyEncoder` codec extension. Together those skip the `url.Parse` + `*http.Request` bookkeeping that `http.NewRequestWithContext` runs on every call. +- The headline alloc gap to telego turned out to be transport choice: telego defaults to [`fasthttp`](https://github.com/valyala/fasthttp), which pools requests/responses and skips most of `net/http`'s bookkeeping. Most of the other libs (and us, by default) use `net/http`. +- We ship an opt-in fasthttp doer (`client.NewFastHTTPDoer`). Plug it via `client.WithHTTPClient(client.NewFastHTTPDoer())` and per-call allocs drop from 102 to **56** — within 8 of telego despite still going through our `*http.Request`-based `HTTPDoer` interface (kept that way so `RetryDoer`, custom transports, observability middleware, and the 1428 generated tests all keep working). +- The default stays `net/http` because fasthttp is HTTP/1.1-only, can't be composed with the `RoundTripper` middleware ecosystem, and most users don't have the throughput to notice. Bots making thousands of API calls/sec should opt in. +- Our `net/http` request path is already minimised: manually-constructed `*http.Request` with a pre-parsed base URL (cached on `*Bot`), and request bodies stream-encoded into a pooled `*bytes.Buffer` via the optional `BodyEncoder` codec extension. Those skip the `url.Parse` + `*http.Request` bookkeeping that `http.NewRequestWithContext` runs on every call. - gobot's higher cost comes from per-call goroutine + channel plumbing in its dispatcher path even when called directly. - **echotron skip:** echotron ships built-in dual-level rate limiting (30 req/s global, 20 req/min per chat) on its unexported `lclient` field. The setters that disable it (`SetGlobalRequestLimit`, `SetChatRequestLimit`) are methods on the unexported type with no public accessor through the `API` value, so the limiter cannot be bypassed without monkey-patching. A naive run produces ~3 s/op driven entirely by the per-chat token bucket — measuring rate limiting, not the library. We skip rather than publish a misleading number. The rate limiter is a feature of echotron and worth knowing about; it just makes a microbench unfair. diff --git a/docs/reference/client.md b/docs/reference/client.md index 8b8ce42..8bde597 100644 --- a/docs/reference/client.md +++ b/docs/reference/client.md @@ -32,6 +32,12 @@ Package client provides HTTP client primitives for the Telegram Bot API. - [func \(DefaultCodec\) Marshal\(v any\) \(\[\]byte, error\)](<#DefaultCodec.Marshal>) - [func \(DefaultCodec\) MarshalTo\(w io.Writer, v any\) error](<#DefaultCodec.MarshalTo>) - [func \(DefaultCodec\) Unmarshal\(data \[\]byte, v any\) error](<#DefaultCodec.Unmarshal>) +- [type FastHTTPDoer](<#FastHTTPDoer>) + - [func NewFastHTTPDoer\(opts ...FastHTTPDoerOption\) \*FastHTTPDoer](<#NewFastHTTPDoer>) + - [func \(d \*FastHTTPDoer\) Do\(req \*http.Request\) \(\*http.Response, error\)](<#FastHTTPDoer.Do>) +- [type FastHTTPDoerOption](<#FastHTTPDoerOption>) + - [func WithFastHTTPClient\(c \*fasthttp.Client\) FastHTTPDoerOption](<#WithFastHTTPClient>) + - [func WithFastHTTPReadTimeout\(t time.Duration\) FastHTTPDoerOption](<#WithFastHTTPReadTimeout>) - [type HTTPDoer](<#HTTPDoer>) - [type Logger](<#Logger>) - [type MultipartFile](<#MultipartFile>) @@ -292,6 +298,72 @@ func (DefaultCodec) Unmarshal(data []byte, v any) error Unmarshal calls json.Unmarshal. + +## type [FastHTTPDoer]() + +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. + +```go +type FastHTTPDoer struct { + // contains filtered or unexported fields +} +``` + + +### func [NewFastHTTPDoer]() + +```go +func NewFastHTTPDoer(opts ...FastHTTPDoerOption) *FastHTTPDoer +``` + +NewFastHTTPDoer constructs a FastHTTPDoer with sensible defaults. + + +### func \(\*FastHTTPDoer\) [Do]() + +```go +func (d *FastHTTPDoer) Do(req *http.Request) (*http.Response, error) +``` + +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. + + +## type [FastHTTPDoerOption]() + +FastHTTPDoerOption configures a FastHTTPDoer. + +```go +type FastHTTPDoerOption func(*FastHTTPDoer) +``` + + +### func [WithFastHTTPClient]() + +```go +func WithFastHTTPClient(c *fasthttp.Client) FastHTTPDoerOption +``` + +WithFastHTTPClient swaps in a pre\-configured \*fasthttp.Client. Useful for sharing a connection pool across multiple bots or applying custom dial / TLS configuration. + + +### func [WithFastHTTPReadTimeout]() + +```go +func WithFastHTTPReadTimeout(t time.Duration) FastHTTPDoerOption +``` + +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. + ## type [HTTPDoer]() diff --git a/go.mod b/go.mod index 9c1d825..5a853c8 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,12 @@ require ( ) require ( + github.com/andybalholm/brotli v1.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7e341ba..5086b21 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,21 @@ +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/test/benchmarks/call_bench_test.go b/test/benchmarks/call_bench_test.go index 94ce3a7..60889f5 100644 --- a/test/benchmarks/call_bench_test.go +++ b/test/benchmarks/call_bench_test.go @@ -28,7 +28,8 @@ import ( // Telegram-format token (digits:[\w-]{35}). telego enforces this format on construction. const benchToken = "1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ_ab123456" -// BenchmarkCall_ours — lukaszraczylo/go-telegram. +// BenchmarkCall_ours — lukaszraczylo/go-telegram with default net/http +// transport. Most users land here. func BenchmarkCall_ours(b *testing.B) { srv := shared.NewMockServer() defer srv.Close() @@ -47,6 +48,30 @@ func BenchmarkCall_ours(b *testing.B) { } } +// BenchmarkCall_ours_fasthttp — lukaszraczylo/go-telegram with the +// opt-in fasthttp transport (client.NewFastHTTPDoer). Apples-to-apples +// against telego, which also runs on fasthttp by default. +func BenchmarkCall_ours_fasthttp(b *testing.B) { + srv := shared.NewMockServer() + defer srv.Close() + bot := client.New(benchToken, + client.WithBaseURL(srv.URL), + client.WithHTTPClient(client.NewFastHTTPDoer()), + ) + ctx := context.Background() + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _, err := api.SendMessage(ctx, bot, &api.SendMessageParams{ + ChatID: api.ChatIDFromInt(42), + Text: "hello", + }) + if err != nil { + b.Fatal(err) + } + } +} + // BenchmarkCall_gotba — go-telegram-bot-api/telegram-bot-api/v5. func BenchmarkCall_gotba(b *testing.B) { srv := shared.NewMockServer() diff --git a/test/benchmarks/go.mod b/test/benchmarks/go.mod index 9825f6b..32af30d 100644 --- a/test/benchmarks/go.mod +++ b/test/benchmarks/go.mod @@ -14,20 +14,20 @@ require ( ) require ( - github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/grbit/go-json v0.11.0 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.69.0 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect github.com/valyala/fastjson v1.6.10 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect - golang.org/x/sys v0.39.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/time v0.5.0 // indirect ) diff --git a/test/benchmarks/go.sum b/test/benchmarks/go.sum index 900bc66..e3c7304 100644 --- a/test/benchmarks/go.sum +++ b/test/benchmarks/go.sum @@ -65,8 +65,8 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= @@ -275,8 +275,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -392,8 +392,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= -github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= @@ -624,8 +624,8 @@ golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=