Streaming
Render.PDF returns a []byte: fine for a 200 KB invoice, wasteful for a 20 MB book, because each goroutine holds the full payload until the response finishes flushing. client.Render.PDFStream returns an io.ReadCloser that yields chunks as the SDK receives them, and polipagegin.PDFStream writes them to the response as they arrive.
How it works
Section titled “How it works”polipagegin.PDFStream(c, reader, opts) reads the reader 32 KB at a time, writes each chunk to c.Writer and calls Flush() after it, so bytes leave the process as they arrive. It sends status 200 with the same headers as polipagegin.PDF (Content-Type, Cache-Control: no-store, private, X-Content-Type-Options: nosniff and the RFC 5987 Content-Disposition; see Responses), plus X-Accel-Buffering: no, which nginx honors per response. The body is chunked, with no Content-Length.
The helper takes an io.Reader, not an io.ReadCloser: the handler owns the stream, so defer body.Close() goes right after the SDK call. A nil reader panics.
PDFStream reads the first chunk before it sets any header. If that read fails, for example with DOWNLOAD_FAILED, nothing is written: the error is queued on c.Errors and the middleware’s error handler answers it as JSON, with no PDF headers.
Render and store, then redirect
Section titled “Render and store, then redirect”When the document also goes to storage or an audit trail, Render.Document returns a *DocumentDescriptor with a presigned URL. Save its DocumentID, redirect the client with polipagegin.DocumentRedirect, and skip the stream. This is cheaper than streaming, since the browser fetches the bytes from object storage.
Gotchas
Section titled “Gotchas”- The stream is consumed once. You cannot retry the response after partial bytes were written. The SDK returns an error before it hands you the reader when the API rejects the request, so a successful
Render.PDFStreamcall already means the API said yes. - No
Content-Length. Clients that need the size up front should use bufferedRender.PDFandpolipagegin.PDF. - Buffering proxies cancel the benefit.
X-Accel-Buffering: noworks for nginx. Cloudflare’s auto-buffering on Pro+ plans ignores it, so disable that for the route. Behind any other proxy, set its per-route equivalent, such asproxy_buffering offon the location block. If buffering cannot be turned off anywhere in the chain, bufferedPDFis the saner default. - Pass
c.Request.Context(). When the browser closes the connection, Gin cancels the request context, the SDK aborts the upstream request, and the next read fails withaborted. Withcontext.Background()the goroutine reads on until the SDK timeout. - Writer wrappers can hide the flusher.
gin.ResponseWriterembedshttp.Flusher, but a custom writer-wrapping middleware may not flush. If streaming feels laggy, look there first.
A failure mid-stream
Section titled “A failure mid-stream”Once the helper has written status 200 and the first chunk, it cannot upgrade to a 500. It queues the error with c.Error(err) and cuts the response so the client sees a truncated transfer (unexpected EOF) instead of a complete-looking PDF:
- It sets a write deadline in the past on the server’s own writer, through
http.ResponseController.net/httpthen closes the HTTP/1.x connection before the chunked body ends (unexpected EOFon the client), or resets the HTTP/2 stream (stream error: ... INTERNAL_ERROR). The handler returns normally, so this holds behindgin.Recovery(). - To reach the server’s writer, it looks through gin’s writer, any wrapper that implements
Unwrap, and any wrapper withoutUnwrapthat embeds its writer, such asgin-contrib/gzip’s. It never calls a wrapper’sHijack. - A wrapper without
Unwrapthat keeps the writer in a named field, or embeds it under an unexported type, hides it: the helper cannot set the deadline, so it panics withhttp.ErrAbortHandler, and behindgin.Recovery()the response then ends as a short200that looks complete. The same panic comes from a writer with no write deadline, such ashttptest.ResponseRecorder. gin.Recovery()catches that panic. Gin v1.9 to v1.11 Recovery logs it with a stack trace. Gin v1.12 Recovery logs the error and a request dump without a stack, and addsnet/http: abort Handlertoc.Errors. If such a wrapper is in your chain, compare the downloaded size with the document’ssizeBytes.- On the panic path, middleware code after
c.Next()(such asgin.Loggeror your own logging ofc.Errors) runs only if a recovery middleware sits inside it. - In your tests, a mid-stream failure through
httptest.NewRecorderorgin.CreateTestContextpanics withhttp.ErrAbortHandlerunless a recovery middleware catches it. Recover it, or assert the panic.
This is tested on Gin v1.9, v1.10, v1.11 and v1.12, the versions CI runs, over HTTP/1.1 and HTTP/2.
Example
Section titled “Example”The sample uses the billing / invoice pair. Picture a very large invoice, such as a quarterly statement with thousands of lines, which is where streaming pays off.
func bigInvoiceHandler(c *gin.Context) { client := polipagegin.ClientFrom(c)
body, err := client.Render.PDFStream(c.Request.Context(), polipage.RenderInput{ Project: "billing", Template: "invoice", Data: map[string]any{"invoiceNumber": c.Param("number")}, }) if err != nil { polipagegin.WriteError(c, err) return } defer body.Close()
polipagegin.PDFStream(c, body, polipagegin.PDFOptions{ Filename: c.Param("number") + ".pdf", Inline: true, })}