fix: build request body up-front so http2 retry works on GOAWAY - #275
fix: build request body up-front so http2 retry works on GOAWAY#275olegkrutikov wants to merge 1 commit into
Conversation
rawRequest streamed the multipart body through an io.Pipe, which is not replayable. http.NewRequestWithContext therefore couldn't derive Request.GetBody, and http2.Transport had no way to retry a POST when Telegram's server sent a GOAWAY frame mid-flight (a routine part of HTTP/2 connection draining). Every POST issued on a draining connection failed with: http2: Transport: cannot retry err [http2: Transport received Server's graceful shutdown GOAWAY] after Request.Body was written; define Request.GetBody to avoid this error Long-poll getUpdates (a GET, no body) was unaffected, so a bot would keep receiving updates while silently failing every sendMessage / editMessageText / answerCallback for the duration of the bad connection. In one production deployment this manifested as the bot going completely mute for ~36 hours after Telegram rotated a server behind it. Build the body into a bytes.Buffer and pass bytes.NewReader to NewRequestWithContext; net/http then auto-populates GetBody and ContentLength, and http2.Transport retries transparently on GOAWAY. Trade-off: the entire request body is held in memory until the request is sent. Most methods are a few KB; file uploads are bounded by Telegram's per-request limit (~50 MB) and only held transiently. Tests: Test_rawRequest_setsGetBody asserts GetBody is non-nil, ContentLength is correct, and two GetBody calls each return bytes identical to the original body.
negasus
left a comment
There was a problem hiding this comment.
Thanks for the detailed writeup and the repro — the diagnosis is right, and this is a real problem worth fixing. http2.Transport can only retry a POST when Request.GetBody is set, and net/http populates it automatically only for *bytes.Reader / *bytes.Buffer / *strings.Reader, never for a pipe. The test covers exactly the contract http2 relies on.
Some context that makes this PR more valuable than it looks: it is effectively a revert of ad3ad98 ("use io.Pipe for build request", #205), which is also where #285 came from. The pre-pipe code had one thing this PR drops:
if fieldsCount > 0 {
httpBody = buf
contentType = form.FormDataContentType()
}Three things before I merge.
1. Please restore the fieldsCount > 0 guard.
Without it, methods with no form fields still declare a multipart body they don't have. I checked the request shape on this branch:
| params | Content-Type | Content-Length | body |
|---|---|---|---|
nil (getMe) |
multipart/form-data; boundary=… |
0 | empty |
| all-empty struct | multipart/form-data; boundary=… |
68 | closing boundary only |
Local telegram-bot-api --local servers reject the first case with an empty 400, which is #285 — bot.New fails with unexpected end of JSON input. Your change fixes the chunked-encoding half of that issue, so it would be good to close it completely here: when there are no fields to send, drop the body and the Content-Type header entirely. buildRequestForm already returns the count, it is just discarded right now.
2. getUpdates is not a GET in this library.
The PR description and the code comment say long-poll getUpdates was unaffected because it is a GET with no body. It actually goes through the same rawRequest POST with a multipart body — offset and timeout are always set (get_updates.go:50). So it hits GOAWAY like everything else; the reason it appeared to survive is that the getUpdates loop retries on its own with timeoutAfterError, while SendMessage surfaces the error to the caller. Doesn't change the fix, but please correct the comment so it doesn't mislead later readers.
3. Memory on uploads — worth a note in the code.
addFormFieldInputFileUpload does io.Copy(w, value.Data) from a caller-supplied reader (build_request_form.go:128), same for InputMedia and sticker attachments. With the pipe, a 50 MB upload streamed with near-zero overhead; buffered, it is fully resident, with a transient peak of up to ~2x while the buffer grows. I am fine taking that trade-off for now — a silently mute bot is much worse than a memory spike — but I would rather it be an explicit, documented decision than an incidental one. If you want to go further and branch on whether params contain an upload (pipe for uploads, buffer otherwise), I am happy to take that too, but it is not a blocker.
Nit: the 18-line comment block above rawRequest is longer than anything else in this codebase. Please trim it to the essentials — that the body is materialised up front so net/http can set GetBody/ContentLength, without which http2 cannot retry a POST after GOAWAY.
|
Hi, I’m the author of #265. I tested this PR against a self-hosted I added the With this change, requests without fields send no body and no I also noticed that custom-marshaled and Tested with:
Feel free to cherry-pick the commit. |
Problem
rawRequeststreams the multipart request body through anio.Pipe. Pipe readers aren't replayable, sohttp.NewRequestWithContextcannot deriveRequest.GetBody, andhttp2.Transporttherefore has no way to retry a POST when Telegram's server sends a GOAWAY frame mid-flight.GOAWAY is a routine part of HTTP/2 connection draining — Telegram emits it during normal load-balancer reassignment / server rotation. Whenever it arrives between the time
client.Dowrites the request line and headers and the time it would receive the response, every in-flight POST on that connection fails with:Long-poll
getUpdatesis a GET (no body), so it isn't affected — net/http retries it transparently. The result is a bot that keeps receiving updates but silently fails everysendMessage/editMessageText/answerCallbackQueryfor as long as the bad connection is reused. In one production deployment this manifested as the bot going completely mute for ~36 hours after Telegram rotated a server behind it; the journal showed the GOAWAY error fanning out across every outbound call simultaneously whilegetUpdatescontinued uninterrupted.This affects every user of the library who reaches Telegram over HTTP/2 (i.e. nearly everyone — Go's default transport negotiates h2 via ALPN). The current workaround is to disable HTTP/2 client-side by setting
Transport.TLSNextPrototo an empty map, which forces HTTP/1.1 keep-alive and avoids the GOAWAY-retry trap entirely.Fix
Build the multipart body into a
bytes.Bufferand passbytes.NewReader(buf.Bytes())toNewRequestWithContext.net/httpthen auto-populatesRequest.GetBodyandRequest.ContentLengthfor*bytes.Reader, andhttp2.Transportretries transparently on GOAWAY.The goroutine that wrote into the pipe is gone, and so is the error-handling that closed it on partial failures. Both became unnecessary once the body is materialised before
client.Dois called.Trade-off
The entire request body is held in memory until the request is sent. For the vast majority of methods (
sendMessage,editMessageText, callback answers, …) the body is a few KB. For file uploads the body is bounded by Telegram's bot-API per-request limit (~50 MB) and only held transiently — comparable to the OS socket buffer the streaming version would queue anyway, and small enough to be a non-issue on any modern host. If a future user needs zero-copy streaming for high-volume large uploads, the seam to reintroduce a streaming path is clear (branch on whetherparamscontains an upload), but I'd argue the current behaviour was a strict bug for everyone else and shouldn't be preserved by default.Test
Test_rawRequest_setsGetBodybuilds asendMessagerequest, captures the resulting*http.Request, and asserts:req.GetBody != nil(precondition for h2 retry).req.ContentLength > 0and matches the body length.req.GetBody()calls each return bytes identical toreq.Body.name="chat_id",name="text", the literal text value).That covers the precise contract
http2.Transportrelies on; the actual retry-on-GOAWAY logic is exercised by net/http's own tests and doesn't need to be re-tested here.go test ./...,go test -race, andgo vet ./...all clean.Reproduction
Run for long enough that Telegram cycles a connection (typically minutes to hours depending on which datacenter you hit). On
mainyou'll eventually see thecannot retry err [http2: ... GOAWAY]error and every subsequent send on that connection fails. With this patch,client.Doretries silently and the loop keeps going.