mirror of
https://github.com/lukaszraczylo/go-telegram.git
synced 2026-06-05 22:43:59 +00:00
75c7ce3119
Two changes that together cut allocs/call from 15 to 13 (client-internal bench) and per-call CPU from 600ns to 455ns (-24%) on the no-HTTP path: 1. Codec gets an optional BodyEncoder extension (MarshalTo io.Writer). When present, encodeJSONBody stream-encodes the request directly into a pooled *bytes.Buffer instead of allocating a [2-step] Marshal+Reader pair. DefaultCodec implements it via goccy/go-json.NewEncoder. 2. *Bot caches the parsed base URL on construction. buildRequest skips net/http.NewRequestWithContext for the common case and constructs *http.Request manually — clones the URL by value, sets the method path, and populates ContentLength + GetBody from the body's concrete type so RetryDoer's body-replay across attempts still works. Cross-library bench (sendMessage round-trip vs httptest.Server): -2 allocs/call (104 -> 102), bytes -1.2%, time within noise (real HTTP plumbing dominates). The CPU win is visible on synthetic stub-doer benches and translates to lower GC pressure on sustained-throughput workloads. Slow-path fallback preserved for codecs that don't implement BodyEncoder and for *Bot instances where url.Parse on the configured base failed — they take the original NewRequestWithContext path.
322 lines
10 KiB
Go
322 lines
10 KiB
Go
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"github.com/goccy/go-json"
|
|
"io"
|
|
"net/http"
|
|
"reflect"
|
|
"sync"
|
|
)
|
|
|
|
var (
|
|
headerJSONValue = []string{"application/json"}
|
|
rawOKTrueBody = []byte(`{"ok":true,"result":true}`)
|
|
rawOKFalseBody = []byte(`{"ok":true,"result":false}`)
|
|
|
|
// respBufPool reuses *bytes.Buffer for response body reads. Used on
|
|
// paths whose decoder copies strings out of the input (decodeResult,
|
|
// which delegates to goccy/go-json), so the buffer can be returned to
|
|
// the pool as soon as Unmarshal has run. CallRaw and callMultipartRaw
|
|
// return slices that alias the buffer and therefore cannot use the
|
|
// pool without an extra copy that would defeat the point.
|
|
respBufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
|
|
|
|
// reqBufPool reuses *bytes.Buffer for request body marshalling on the
|
|
// JSON path. Only used when the configured Codec satisfies BodyEncoder
|
|
// so we can stream-encode into the buffer instead of allocating an
|
|
// intermediate []byte. The buffer is safe to return to the pool once
|
|
// http.Client.Do (or RetryDoer, which io.ReadAlls the body up front)
|
|
// has consumed it.
|
|
reqBufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
|
|
)
|
|
|
|
// maxPooledBufCap caps the buffer size returned to either pool. Buffers
|
|
// larger than this are dropped on the floor so a single huge response
|
|
// (e.g. a large getFile metadata payload) doesn't bloat the pool for the
|
|
// rest of the process lifetime.
|
|
const maxPooledBufCap = 64 * 1024
|
|
|
|
func putRespBuf(buf *bytes.Buffer) {
|
|
if buf.Cap() > maxPooledBufCap {
|
|
return
|
|
}
|
|
respBufPool.Put(buf)
|
|
}
|
|
|
|
func putReqBuf(buf *bytes.Buffer) {
|
|
if buf.Cap() > maxPooledBufCap {
|
|
return
|
|
}
|
|
reqBufPool.Put(buf)
|
|
}
|
|
|
|
// Call is the single point through which every Telegram Bot API method
|
|
// invocation flows. It marshals the request, signs the URL with the bot
|
|
// token, dispatches via HTTPDoer, decodes the Result envelope, and
|
|
// translates non-OK responses into typed errors.
|
|
//
|
|
// It is generic over both request and response types. Methods with no
|
|
// parameters may pass a nil Req; the helper sends "{}" in that case so
|
|
// Telegram receives a syntactically valid empty object.
|
|
//
|
|
// Call is exported because the api package (which lives outside this one)
|
|
// invokes it from generated method wrappers. User code should not normally
|
|
// call it directly — use the typed wrappers in package api instead.
|
|
func Call[Req any, Resp any](ctx context.Context, b *Bot, method string, req Req) (Resp, error) {
|
|
var zero Resp
|
|
|
|
if mp, ok := any(req).(multipartRequest); ok {
|
|
if mp == nil {
|
|
return zero, &ParseError{Err: errors.New("client: nil multipart request")}
|
|
}
|
|
if mp.HasFile() {
|
|
return callMultipart[Resp](ctx, b, method, mp)
|
|
}
|
|
}
|
|
|
|
body, pooledReqBuf, err := encodeJSONBody(b.codec, req)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
if pooledReqBuf != nil {
|
|
defer putReqBuf(pooledReqBuf)
|
|
}
|
|
|
|
httpReq, err := b.buildRequest(ctx, method, body)
|
|
if err != nil {
|
|
return zero, &NetworkError{Err: err}
|
|
}
|
|
|
|
resp, err := b.http.Do(httpReq)
|
|
if err != nil {
|
|
// Surface ctx errors faithfully so callers can errors.Is(err, ctx.Err()).
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
return zero, ctxErr
|
|
}
|
|
return zero, &NetworkError{Err: err}
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
buf := respBufPool.Get().(*bytes.Buffer)
|
|
buf.Reset()
|
|
defer putRespBuf(buf)
|
|
if _, err := buf.ReadFrom(resp.Body); err != nil {
|
|
return zero, &NetworkError{Err: err}
|
|
}
|
|
|
|
return decodeResult[Resp](b.codec, buf.Bytes())
|
|
}
|
|
|
|
// CallRaw is like Call but returns the raw JSON of the result field
|
|
// instead of decoding it into a typed value. Generated method wrappers
|
|
// for sealed-interface return types (ChatMember, MenuButton, etc.) use
|
|
// this helper, then dispatch through the union's UnmarshalXxx function.
|
|
//
|
|
// CallRaw still translates non-OK responses into *APIError just like Call.
|
|
func CallRaw[Req any](ctx context.Context, b *Bot, method string, req Req) (json.RawMessage, error) {
|
|
if mp, ok := any(req).(multipartRequest); ok {
|
|
if mp == nil {
|
|
return nil, &ParseError{Err: errors.New("client: nil multipart request")}
|
|
}
|
|
if mp.HasFile() {
|
|
return callMultipartRaw(ctx, b, method, mp)
|
|
}
|
|
}
|
|
|
|
body, pooledReqBuf, err := encodeJSONBody(b.codec, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if pooledReqBuf != nil {
|
|
defer putReqBuf(pooledReqBuf)
|
|
}
|
|
|
|
httpReq, err := b.buildRequest(ctx, method, body)
|
|
if err != nil {
|
|
return nil, &NetworkError{Err: err}
|
|
}
|
|
|
|
resp, err := b.http.Do(httpReq)
|
|
if err != nil {
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
return nil, ctxErr
|
|
}
|
|
return nil, &NetworkError{Err: err}
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, &NetworkError{Err: err}
|
|
}
|
|
|
|
return decodeResultRaw(b.codec, raw)
|
|
}
|
|
|
|
// decodeResultRaw is decodeResult's sibling that returns the raw result
|
|
// field instead of typing it.
|
|
func decodeResultRaw(codec Codec, raw []byte) (json.RawMessage, error) {
|
|
var env Result[json.RawMessage]
|
|
if err := codec.Unmarshal(raw, &env); err != nil {
|
|
return nil, &ParseError{Err: err, Body: copyBody(raw)}
|
|
}
|
|
if !env.OK {
|
|
return nil, mapAPIError(env.ErrorCode, env.Description, env.Parameters)
|
|
}
|
|
return env.Result, nil
|
|
}
|
|
|
|
// buildRequest constructs the *http.Request for an API call. When the bot
|
|
// has a cached parsed base URL (the common path), the request is built
|
|
// manually so that net/url.Parse and net/http.NewRequestWithContext's
|
|
// internal book-keeping are skipped — saving allocations on every call.
|
|
//
|
|
// ContentLength and GetBody are populated from the body's concrete type
|
|
// in bodyToReadCloser so RetryDoer can replay the body across attempts.
|
|
func (b *Bot) buildRequest(ctx context.Context, method string, body io.Reader) (*http.Request, error) {
|
|
if b.baseURL == nil {
|
|
// Slow path: WithBaseURL configured an unparsable URL (or New ran
|
|
// before pre-parse for some reason). Fall back to the stdlib
|
|
// constructor so we still produce a valid request.
|
|
url := b.base + b.pathPrefix + method
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header["Content-Type"] = headerJSONValue
|
|
req.Header["Accept"] = headerJSONValue
|
|
return req, nil
|
|
}
|
|
|
|
// Fast path: clone the cached *url.URL by value, set the per-method
|
|
// path. Constructing &http.Request{} directly avoids the Header,
|
|
// URL-parse, and ContentLength bookkeeping that NewRequestWithContext
|
|
// runs unconditionally.
|
|
u := *b.baseURL
|
|
u.Path = b.pathPrefix + method
|
|
u.RawPath = ""
|
|
|
|
rc, contentLength, getBody := bodyToReadCloser(body)
|
|
req := &http.Request{
|
|
Method: http.MethodPost,
|
|
URL: &u,
|
|
Proto: "HTTP/1.1",
|
|
ProtoMajor: 1,
|
|
ProtoMinor: 1,
|
|
Header: http.Header{"Content-Type": headerJSONValue, "Accept": headerJSONValue},
|
|
Body: rc,
|
|
GetBody: getBody,
|
|
ContentLength: contentLength,
|
|
Host: u.Host,
|
|
}
|
|
return req.WithContext(ctx), 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
|
|
// (Marshal fallback for codecs that don't implement BodyEncoder). Both
|
|
// cases populate ContentLength and GetBody so RetryDoer can replay the
|
|
// body across retry attempts without buffering it again.
|
|
func bodyToReadCloser(body io.Reader) (io.ReadCloser, int64, func() (io.ReadCloser, error)) {
|
|
switch v := body.(type) {
|
|
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
|
|
}
|
|
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) {
|
|
s := snapshot
|
|
return io.NopCloser(&s), nil
|
|
}
|
|
default:
|
|
// Unknown reader: no length, no replay. Should not happen with the
|
|
// current encodeJSONBody body shapes but kept for forward safety.
|
|
return io.NopCloser(body), -1, nil
|
|
}
|
|
}
|
|
|
|
// encodeJSONBody marshals req into a JSON body. It returns the body
|
|
// reader plus, when the codec satisfies BodyEncoder, the pooled buffer
|
|
// that backs it — callers MUST return that buffer to the pool via
|
|
// putReqBuf once the request is done. The buffer return is exposed
|
|
// directly (instead of a closure) so encodeJSONBody allocates nothing
|
|
// on the pooled path beyond the codec's own internal allocations.
|
|
//
|
|
// The {} fast path used for nil/nil-pointer requests bypasses the pool
|
|
// entirely; the 2-byte literal isn't worth the contention overhead.
|
|
func encodeJSONBody(codec Codec, req any) (io.Reader, *bytes.Buffer, error) {
|
|
if req == nil || isNilPointer(req) {
|
|
return bytes.NewBufferString("{}"), nil, nil
|
|
}
|
|
if enc, ok := codec.(BodyEncoder); ok {
|
|
buf := reqBufPool.Get().(*bytes.Buffer)
|
|
buf.Reset()
|
|
if err := enc.MarshalTo(buf, req); err != nil {
|
|
putReqBuf(buf)
|
|
return nil, nil, &ParseError{Err: err}
|
|
}
|
|
return buf, buf, nil
|
|
}
|
|
data, err := codec.Marshal(req)
|
|
if err != nil {
|
|
return nil, nil, &ParseError{Err: err}
|
|
}
|
|
return bytes.NewReader(data), nil, nil
|
|
}
|
|
|
|
// decodeResult unmarshals raw into Result[Resp] and translates non-OK
|
|
// responses into *APIError.
|
|
//
|
|
// Bool fast path: ~60% of Telegram methods return bool. The Telegram API
|
|
// emits the result envelope with no whitespace, so a byte-equality check
|
|
// against the two canonical bodies skips the generic Unmarshal entirely.
|
|
// Anything that doesn't match exactly (e.g. responses with extra fields,
|
|
// errors) falls through to the slow path.
|
|
func decodeResult[Resp any](codec Codec, raw []byte) (Resp, error) {
|
|
var zero Resp
|
|
if _, isBool := any(zero).(bool); isBool {
|
|
switch {
|
|
case bytes.Equal(raw, rawOKTrueBody):
|
|
return any(true).(Resp), nil
|
|
case bytes.Equal(raw, rawOKFalseBody):
|
|
return any(false).(Resp), nil
|
|
}
|
|
}
|
|
var env Result[Resp]
|
|
if err := codec.Unmarshal(raw, &env); err != nil {
|
|
return zero, &ParseError{Err: err, Body: copyBody(raw)}
|
|
}
|
|
if !env.OK {
|
|
return zero, mapAPIError(env.ErrorCode, env.Description, env.Parameters)
|
|
}
|
|
return env.Result, nil
|
|
}
|
|
|
|
// isNilPointer returns true when v is a typed nil pointer (the interface
|
|
// itself is non-nil because it carries a type, but the underlying value
|
|
// is nil). One reflect call per request; not on a hot path that demands
|
|
// allocation-freedom.
|
|
func isNilPointer(v any) bool {
|
|
rv := reflect.ValueOf(v)
|
|
return rv.Kind() == reflect.Ptr && rv.IsNil()
|
|
}
|
|
|
|
func copyBody(b []byte) []byte {
|
|
const max = 4096
|
|
if len(b) > max {
|
|
b = b[:max]
|
|
}
|
|
out := make([]byte, len(b))
|
|
copy(out, b)
|
|
return out
|
|
}
|