May 20, 2026

PDF API in Go: Goroutines, Retries & Production Patterns

GoPDF APIconcurrencyproduction

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 — not len(jobs).
  • GenerateWithRetry is 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

Powered by FUNBREW PDF