PDF API in Go: Goroutines, Retries & Production Patterns
Go's concurrency model makes it uniquely well-suited for high-throughput PDF generation. A single goroutine per request is cheap, and channels make it easy to build a bounded worker pool without third-party dependencies. This guide focuses on production-grade Go patterns — goroutine-based parallelism, retry logic, context propagation, and Gin/Echo integration.
For a quick comparison of Ruby and Go with basic code examples, see the Ruby and Go quickstart guide. For Node.js, Python, and PHP, see the language quickstart guide.
Prerequisites
# Set your API key
export FUNBREW_PDF_API_KEY="sk-your-api-key"
Sign up for free to get your API key. See the API documentation for the full option reference.
Basic Single PDF Generation
Start with a minimal, production-ready struct that wraps the API call cleanly.
package pdfapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
// Client holds a shared http.Client — reuse across requests.
type Client struct {
httpClient *http.Client
apiKey string
baseURL string
}
// NewClient creates a PDF API client with sensible production defaults.
func NewClient() *Client {
return &Client{
httpClient: &http.Client{
Timeout: 60 * time.Second,
// Share a single transport to benefit from connection pooling.
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
},
},
apiKey: os.Getenv("FUNBREW_PDF_API_KEY"),
baseURL: "https://pdf.funbrew.cloud/api/v1",
}
}
type GenerateRequest struct {
HTML string `json:"html"`
Options map[string]any `json:"options,omitempty"`
}
// Generate sends an HTML string and returns PDF bytes.
func (c *Client) Generate(ctx context.Context, req GenerateRequest) ([]byte, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/pdf/generate", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
httpReq.Header.Set("X-API-Key", c.apiKey)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errBody, _ := io.ReadAll(resp.Body)
return nil, &APIError{Status: resp.StatusCode, Body: string(errBody)}
}
return io.ReadAll(resp.Body)
}
// APIError carries the HTTP status and raw body for structured error handling.
type APIError struct {
Status int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("PDF API %d: %s", e.Status, e.Body)
}
func (e *APIError) IsRetryable() bool {
return e.Status == 429 || e.Status >= 500
}
func main() {
client := pdfapi.NewClient()
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
pdf, err := client.Generate(ctx, pdfapi.GenerateRequest{
HTML: `<!DOCTYPE html>
<html lang="en">
<head><style>
body { font-family: Arial, sans-serif; padding: 40px; color: #1a202c; }
h1 { font-size: 24pt; border-bottom: 2px solid #3b82f6; padding-bottom: 8px; }
.meta { color: #718096; font-size: 10pt; margin-top: 4px; }
</style></head>
<body>
<h1>Monthly Report</h1>
<p class="meta">Generated via FUNBREW PDF API</p>
<p>Report content goes here.</p>
</body>
</html>`,
Options: map[string]any{
"format": "A4",
"engine": "quality",
"margin": map[string]string{
"top": "20mm", "bottom": "20mm",
"left": "15mm", "right": "15mm",
},
},
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
os.WriteFile("report.pdf", pdf, 0644)
fmt.Printf("Generated report.pdf (%d bytes)\n", len(pdf))
}
Exponential Backoff with Retry
Rate limits (HTTP 429) and transient server errors (5xx) happen in production. Wrap Generate with a retry loop using exponential backoff.
package pdfapi
import (
"context"
"errors"
"math"
"time"
)
// RetryConfig controls retry behavior.
type RetryConfig struct {
MaxAttempts int
BaseDelay time.Duration
MaxDelay time.Duration
}
// DefaultRetryConfig is a reasonable starting point for production.
var DefaultRetryConfig = RetryConfig{
MaxAttempts: 4,
BaseDelay: 500 * time.Millisecond,
MaxDelay: 30 * time.Second,
}
// GenerateWithRetry wraps Generate with exponential backoff.
func (c *Client) GenerateWithRetry(ctx context.Context, req GenerateRequest, cfg RetryConfig) ([]byte, error) {
var lastErr error
for attempt := 0; attempt < cfg.MaxAttempts; attempt++ {
pdf, err := c.Generate(ctx, req)
if err == nil {
return pdf, nil
}
var apiErr *APIError
if !errors.As(err, &apiErr) || !apiErr.IsRetryable() {
// Non-retryable error (e.g. 400 Bad Request) — fail immediately.
return nil, err
}
lastErr = err
// Calculate delay: base * 2^attempt, capped at MaxDelay.
delay := time.Duration(float64(cfg.BaseDelay) * math.Pow(2, float64(attempt)))
if delay > cfg.MaxDelay {
delay = cfg.MaxDelay
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
return nil, fmt.Errorf("exceeded %d attempts: %w", cfg.MaxAttempts, lastErr)
}
Usage:
pdf, err := client.GenerateWithRetry(ctx, req, pdfapi.DefaultRetryConfig)
Goroutine Worker Pool for Batch Generation
The quickstart guide shows a simple sync.WaitGroup with a semaphore channel. For production batch jobs, a dedicated worker pool is more maintainable and observable.
package main
import (
"context"
"fmt"
"os"
"sync"
"time"
)
// Job represents a single PDF generation task.
type Job struct {
ID string
HTML string
}
// Result holds the outcome of a single generation.
type Result struct {
ID string
PDF []byte
Bytes int
Err error
}
// BatchGenerate runs up to `concurrency` goroutines in parallel.
// It returns results in the same order as the input slice.
func BatchGenerate(ctx context.Context, client *pdfapi.Client, jobs []Job, concurrency int) []Result {
results := make([]Result, len(jobs))
type indexedJob struct {
index int
job Job
}
jobCh := make(chan indexedJob, len(jobs))
var wg sync.WaitGroup
// Spawn worker goroutines.
for w := 0; w < concurrency; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for ij := range jobCh {
pdf, err := client.GenerateWithRetry(ctx, pdfapi.GenerateRequest{
HTML: ij.job.HTML,
Options: map[string]any{
"format": "A4",
"engine": "quality",
},
}, pdfapi.DefaultRetryConfig)
results[ij.index] = Result{
ID: ij.job.ID,
PDF: pdf,
Bytes: len(pdf),
Err: err,
}
}
}()
}
// Enqueue all jobs.
for i, j := range jobs {
jobCh <- indexedJob{index: i, job: j}
}
close(jobCh)
wg.Wait()
return results
}
func main() {
client := pdfapi.NewClient()
jobs := []Job{
{ID: "INV-001", HTML: "<h1>Invoice 001</h1><p>Acme Corp — $1,000</p>"},
{ID: "INV-002", HTML: "<h1>Invoice 002</h1><p>Globex Inc — $2,500</p>"},
{ID: "INV-003", HTML: "<h1>Invoice 003</h1><p>Initech — $1,800</p>"},
// Add as many as needed — the pool handles concurrency.
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
// 5 concurrent goroutines — adjust based on your API plan's rate limit.
results := BatchGenerate(ctx, client, jobs, 5)
for _, r := range results {
if r.Err != nil {
fmt.Fprintf(os.Stderr, "%s: FAILED — %v\n", r.ID, r.Err)
continue
}
path := r.ID + ".pdf"
os.WriteFile(path, r.PDF, 0644)
fmt.Printf("%s: OK — %d bytes → %s\n", r.ID, r.Bytes, path)
}
}
Key differences from the basic goroutine example in the quickstart guide:
- Results are written to a pre-allocated slice at the original job index, preserving order without a mutex.
- Workers block on a buffered channel, so goroutine count is exactly
concurrency— notlen(jobs). GenerateWithRetryis called inside the worker, so retries stay inside the pool.
Gin Integration
package main
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// PDFHandler is a Gin handler that generates PDFs on demand.
// Inject the client at startup and reuse it across requests.
type PDFHandler struct {
client *pdfapi.Client
}
func (h *PDFHandler) Download(c *gin.Context) {
invoiceID := c.Param("id")
// In production, fetch invoice data from your database here.
html := buildInvoiceHTML(invoiceID)
ctx, cancel := context.WithTimeout(c.Request.Context(), 45*time.Second)
defer cancel()
pdf, err := h.client.GenerateWithRetry(ctx, pdfapi.GenerateRequest{
HTML: html,
Options: map[string]any{"format": "A4", "engine": "quality"},
}, pdfapi.DefaultRetryConfig)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Header("Content-Disposition", `attachment; filename="invoice-`+invoiceID+`.pdf"`)
c.Data(http.StatusOK, "application/pdf", pdf)
}
func buildInvoiceHTML(id string) string {
return `<!DOCTYPE html><html><head><style>
body { font-family: Arial, sans-serif; padding: 40px; }
h1 { color: #1a202c; }
</style></head><body>
<h1>Invoice ` + id + `</h1>
<p>Generated by FUNBREW PDF</p>
</body></html>`
}
func main() {
client := pdfapi.NewClient()
handler := &PDFHandler{client: client}
r := gin.Default()
r.GET("/invoices/:id/pdf", handler.Download)
r.Run(":8080")
}
Echo Integration
package main
import (
"context"
"net/http"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
type PDFService struct {
client *pdfapi.Client
}
func (s *PDFService) DownloadReport(c echo.Context) error {
reportID := c.Param("id")
ctx, cancel := context.WithTimeout(c.Request().Context(), 45*time.Second)
defer cancel()
pdf, err := s.client.GenerateWithRetry(ctx, pdfapi.GenerateRequest{
HTML: `<h1>Report ` + reportID + `</h1><p>FUNBREW PDF</p>`,
Options: map[string]any{"format": "A4", "engine": "quality"},
}, pdfapi.DefaultRetryConfig)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
c.Response().Header().Set("Content-Disposition",
`attachment; filename="report-`+reportID+`.pdf"`)
return c.Blob(http.StatusOK, "application/pdf", pdf)
}
func main() {
svc := &PDFService{client: pdfapi.NewClient()}
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.GET("/reports/:id/pdf", svc.DownloadReport)
e.Logger.Fatal(e.Start(":8080"))
}
Production Deployment Patterns
Environment-Based Configuration
package config
import (
"fmt"
"os"
"strconv"
"time"
)
type PDFConfig struct {
APIKey string
BaseURL string
Timeout time.Duration
Concurrency int
MaxRetries int
}
func LoadPDFConfig() (*PDFConfig, error) {
apiKey := os.Getenv("FUNBREW_PDF_API_KEY")
if apiKey == "" {
return nil, fmt.Errorf("FUNBREW_PDF_API_KEY is required")
}
concurrency, _ := strconv.Atoi(os.Getenv("PDF_CONCURRENCY"))
if concurrency <= 0 {
concurrency = 5
}
timeout, _ := strconv.Atoi(os.Getenv("PDF_TIMEOUT_SECONDS"))
if timeout <= 0 {
timeout = 60
}
maxRetries, _ := strconv.Atoi(os.Getenv("PDF_MAX_RETRIES"))
if maxRetries <= 0 {
maxRetries = 4
}
return &PDFConfig{
APIKey: apiKey,
BaseURL: "https://pdf.funbrew.cloud/api/v1",
Timeout: time.Duration(timeout) * time.Second,
Concurrency: concurrency,
MaxRetries: maxRetries,
}, nil
}
Observability: Logging and Metrics
Wrap the client to add structured logging and latency metrics.
package pdfapi
import (
"context"
"log/slog"
"time"
)
// InstrumentedClient wraps Client with logging and metrics.
type InstrumentedClient struct {
*Client
logger *slog.Logger
}
func NewInstrumentedClient(logger *slog.Logger) *InstrumentedClient {
return &InstrumentedClient{
Client: NewClient(),
logger: logger,
}
}
func (ic *InstrumentedClient) Generate(ctx context.Context, req GenerateRequest) ([]byte, error) {
start := time.Now()
pdf, err := ic.Client.Generate(ctx, req)
duration := time.Since(start)
if err != nil {
ic.logger.Error("pdf generation failed",
"duration_ms", duration.Milliseconds(),
"error", err,
)
// Increment your metrics counter here, e.g. prometheus.
return nil, err
}
ic.logger.Info("pdf generated",
"duration_ms", duration.Milliseconds(),
"size_bytes", len(pdf),
)
return pdf, nil
}
Graceful Shutdown for Batch Workers
When running a batch worker as a long-running service, honour SIGTERM so in-flight jobs complete before the process exits.
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
client := pdfapi.NewClient()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
jobs := loadJobsFromQueue() // Your queue/DB call.
// Give in-flight jobs up to 2 minutes to complete after signal.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
results := BatchGenerate(shutdownCtx, client, jobs, 5)
for _, r := range results {
if r.Err != nil {
fmt.Fprintf(os.Stderr, "%s: FAILED — %v\n", r.ID, r.Err)
} else {
fmt.Printf("%s: OK — %d bytes\n", r.ID, r.Bytes)
}
}
}
Connection Pool Tuning
The default http.Transport settings work fine up to moderate throughput. For high-concurrency batches, tune the transport:
transport := &http.Transport{
// Match MaxIdleConnsPerHost to your worker pool size.
MaxIdleConnsPerHost: 20,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
// Reduce dial timeout for fast-fail on network issues.
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
}
Summary
| Pattern | When to use |
|---|---|
Single Generate call |
On-demand PDF in an HTTP handler |
GenerateWithRetry |
Any production path — rate limits happen |
Worker pool (BatchGenerate) |
Invoice runs, nightly report jobs, data exports |
InstrumentedClient |
Any service where you need latency metrics |
signal.NotifyContext |
Long-running batch workers in Kubernetes/systemd |
Go's context package integrates naturally with the PDF API: pass the request context from Gin/Echo into Generate and cancellation propagates automatically. Every connection is reused through the shared http.Transport, so you pay no TLS handshake overhead on repeated calls.
Related
- Ruby and Go Quickstart — Basic Ruby and Go code samples with Faraday, Resty, and net/http
- PDF API Quickstart by Language — Node.js, Python, PHP examples
- Batch PDF Generation Guide — Queue-based patterns for large-scale generation
- PDF API Error Handling Guide — Error types, retry strategies, and circuit breakers
- API Documentation — Full FUNBREW PDF option reference
- Playground — Test HTML-to-PDF in the browser before writing code
- Use Cases — Invoices, reports, certificates, and more