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.
This commit is contained in:
2026-05-10 23:07:04 +01:00
parent 75c7ce3119
commit bfb7e9875e
11 changed files with 481 additions and 23 deletions
+8 -5
View File
@@ -20,7 +20,7 @@
- **Webhook decode** (small Update): ours is **1220% faster** than every competitor and ties telego for the lowest alloc count (11).
- **Large Update unmarshal** (entities + reply markup + photo array): ours is **1734% 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.52.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.
+72
View File
@@ -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.
<a name="FastHTTPDoer"></a>
## type [FastHTTPDoer](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/fasthttp_doer.go#L26-L32>)
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
}
```
<a name="NewFastHTTPDoer"></a>
### func [NewFastHTTPDoer](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/fasthttp_doer.go#L52>)
```go
func NewFastHTTPDoer(opts ...FastHTTPDoerOption) *FastHTTPDoer
```
NewFastHTTPDoer constructs a FastHTTPDoer with sensible defaults.
<a name="FastHTTPDoer.Do"></a>
### func \(\*FastHTTPDoer\) [Do](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/fasthttp_doer.go#L75>)
```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.
<a name="FastHTTPDoerOption"></a>
## type [FastHTTPDoerOption](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/fasthttp_doer.go#L35>)
FastHTTPDoerOption configures a FastHTTPDoer.
```go
type FastHTTPDoerOption func(*FastHTTPDoer)
```
<a name="WithFastHTTPClient"></a>
### func [WithFastHTTPClient](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/fasthttp_doer.go#L40>)
```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.
<a name="WithFastHTTPReadTimeout"></a>
### func [WithFastHTTPReadTimeout](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/fasthttp_doer.go#L47>)
```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.
<a name="HTTPDoer"></a>
## type [HTTPDoer](<https://github.com/lukaszraczylo/go-telegram/blob/main/client/httpclient.go#L13-L15>)