PDF API in Ruby: Rails, Sidekiq & Production Patterns
Ruby's ecosystem — Faraday for HTTP, Sidekiq for async jobs, Rails for the web layer — maps cleanly onto PDF API integration. This guide focuses on production-grade Ruby patterns: a reusable service object, retry middleware, async PDF generation via Sidekiq, and webhook-based delivery.
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. If you prefer Go's goroutine-based approach, see the Go PDF API Guide.
Prerequisites
# Set your API key
export FUNBREW_PDF_API_KEY="sk-your-api-key"
# Gemfile
gem 'faraday'
gem 'faraday-retry'
gem 'sidekiq' # For async generation
Sign up for free to get your API key. See the API documentation for the full option reference.
Service Object Pattern
Encapsulate all PDF API calls in a single service class. This makes it easy to swap implementations, add logging, and test in isolation.
# app/services/pdf_generator.rb
require 'faraday'
require 'faraday/retry'
require 'json'
class PdfGenerator
BASE_URL = 'https://api.pdf.funbrew.cloud'.freeze
# Retry on rate limits and transient server errors.
RETRY_OPTIONS = {
max: 3,
interval: 1.0,
backoff_factor: 2,
retry_statuses: [429, 500, 502, 503],
retry_if: ->(_env, _err) { true }
}.freeze
def initialize(api_key: ENV.fetch('FUNBREW_PDF_API_KEY'))
@conn = Faraday.new(url: BASE_URL) do |f|
f.request :retry, RETRY_OPTIONS
f.options.timeout = 60
f.options.open_timeout = 10
f.headers['Authorization'] = "Bearer #{api_key}"
f.headers['Content-Type'] = 'application/json'
end
end
# Generate from raw HTML. Returns PDF binary on success, raises on error.
def from_html(html, format: 'A4', engine: 'quality', **opts)
payload = { html: html, engine: engine, format: format }.merge(opts)
response = @conn.post('/v1/pdf/from-html', payload.to_json)
raise PdfGenerationError.new(response.status, response.body) unless response.success?
response.body
end
# Generate from a registered template.
def from_template(template_id, data, format: 'A4')
payload = { template_id: template_id, data: data, format: format }
response = @conn.post('/v1/pdf/from-template', payload.to_json)
raise PdfGenerationError.new(response.status, response.body) unless response.success?
response.body
end
# Convert a URL to PDF.
def from_url(url, format: 'A4', wait_for: 'networkidle')
payload = { url: url, format: format, wait_for: wait_for }
response = @conn.post('/v1/pdf/from-url', payload.to_json)
raise PdfGenerationError.new(response.status, response.body) unless response.success?
response.body
end
class PdfGenerationError < StandardError
attr_reader :status, :body
def initialize(status, body)
@status = status
@body = body
super("PDF API error #{status}: #{body}")
end
def retryable?
[429, 500, 502, 503].include?(@status)
end
end
end
Usage anywhere in your Rails app:
generator = PdfGenerator.new
# On-demand HTML generation
pdf = generator.from_html('<h1>Invoice</h1><p>Total: $1,000</p>')
File.binwrite('invoice.pdf', pdf)
# Template-based generation
pdf = generator.from_template('invoice-v1', {
company_name: 'Acme Corp',
invoice_number: 'INV-2026-0042',
items: [
{ name: 'Web Consulting', quantity: 1, price: 1500 },
{ name: 'Design Work', quantity: 3, price: 500 },
],
total: 3000
})
Rails Controller Integration
Serve PDFs as inline previews or forced downloads from a Rails controller.
# app/controllers/invoices_controller.rb
class InvoicesController < ApplicationController
before_action :set_invoice, only: [:pdf]
# GET /invoices/:id.pdf
def pdf
generator = PdfGenerator.new
pdf_bytes = generator.from_template('invoice-v1', invoice_payload)
respond_to do |format|
format.pdf do
send_data pdf_bytes,
filename: "invoice-#{@invoice.number}.pdf",
type: 'application/pdf',
disposition: params[:download] ? 'attachment' : 'inline'
end
end
rescue PdfGenerator::PdfGenerationError => e
Rails.logger.error("PDF generation failed: #{e.message}")
render json: { error: 'PDF generation failed' }, status: :service_unavailable
end
private
def set_invoice
@invoice = Invoice.find(params[:id])
authorize @invoice # Pundit / CanCanCan authorization
end
def invoice_payload
{
company_name: @invoice.company_name,
invoice_number: @invoice.number,
issued_at: @invoice.issued_at.strftime('%Y-%m-%d'),
items: @invoice.line_items.map do |item|
{ name: item.name, quantity: item.quantity, price: item.unit_price_cents / 100.0 }
end,
total: @invoice.total_cents / 100.0
}
end
end
Register the MIME type in config/initializers/mime_types.rb:
Mime::Type.register 'application/pdf', :pdf
Sidekiq Async PDF Generation
For large or low-priority PDFs, move generation off the request thread. Sidekiq workers are the standard pattern in Rails apps.
# app/jobs/generate_invoice_pdf_job.rb
class GenerateInvoicePdfJob
include Sidekiq::Job
sidekiq_options queue: 'pdf', retry: 3
def perform(invoice_id, notify_user: true)
invoice = Invoice.find(invoice_id)
generator = PdfGenerator.new
pdf_bytes = generator.from_template('invoice-v1', invoice_payload(invoice))
# Store in Active Storage (or S3 / GCS directly)
invoice.pdf_attachment.attach(
io: StringIO.new(pdf_bytes),
filename: "invoice-#{invoice.number}.pdf",
content_type: 'application/pdf'
)
invoice.update!(pdf_generated_at: Time.current)
# Email the PDF to the customer
InvoiceMailer.pdf_ready(invoice).deliver_later if notify_user
rescue PdfGenerator::PdfGenerationError => e
Rails.logger.error("[GenerateInvoicePdfJob] Invoice #{invoice_id} failed: #{e.message}")
raise # Let Sidekiq's retry mechanism handle it
end
private
def invoice_payload(invoice)
{
company_name: invoice.company_name,
invoice_number: invoice.number,
issued_at: invoice.issued_at.strftime('%Y-%m-%d'),
items: invoice.line_items.map { |i|
{ name: i.name, quantity: i.quantity, price: i.unit_price_cents / 100.0 }
},
total: invoice.total_cents / 100.0
}
end
end
Enqueue from a controller or model callback:
# Enqueue after invoice creation
class Invoice < ApplicationRecord
after_commit :enqueue_pdf_generation, on: :create
private
def enqueue_pdf_generation
GenerateInvoicePdfJob.perform_async(id)
end
end
Sidekiq Configuration
# config/sidekiq.yml
:queues:
- [critical, 3]
- [default, 2]
- [pdf, 1] # PDF generation — lower priority than critical paths
:concurrency: 10
Set a longer timeout for PDF jobs since generation can take several seconds:
# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
config.server_middleware do |chain|
# Optional: add instrumentation middleware here
end
end
Batch PDF Generation
For month-end invoice runs or bulk certificate issuance, generate PDFs in parallel using Ruby threads.
# lib/pdf_batch_processor.rb
require 'parallel'
class PdfBatchProcessor
DEFAULT_CONCURRENCY = 5
def initialize(concurrency: DEFAULT_CONCURRENCY)
@concurrency = concurrency
# Each thread needs its own Faraday connection — PdfGenerator is not thread-safe
# because Faraday connections are not designed to be shared across threads.
@thread_local_generator = Hash.new { |h, k| h[k] = PdfGenerator.new }
end
# Generates PDFs for all items in parallel.
# Returns array of { id:, status: :ok/:error, path:, error: } hashes.
def run(items, &html_builder)
Parallel.map(items, in_threads: @concurrency) do |item|
generator = @thread_local_generator[Thread.current.object_id]
begin
html = html_builder.call(item)
pdf_bytes = generator.from_html(html, engine: 'fast')
path = write_pdf(item[:id], pdf_bytes)
{ id: item[:id], status: :ok, path: path, bytes: pdf_bytes.bytesize }
rescue PdfGenerator::PdfGenerationError => e
{ id: item[:id], status: :error, error: e.message }
end
end
end
private
def write_pdf(id, bytes)
path = Rails.root.join('tmp', 'pdfs', "#{id}.pdf")
FileUtils.mkdir_p(path.dirname)
File.binwrite(path, bytes)
path.to_s
end
end
Usage for a month-end invoice run:
invoices = Invoice.where(period: Date.today.beginning_of_month..)
processor = PdfBatchProcessor.new(concurrency: 5)
results = processor.run(invoices.map { |i| { id: i.id, invoice: i } }) do |item|
ApplicationController.render(
template: 'invoices/pdf',
assigns: { invoice: item[:invoice] },
layout: 'pdf'
)
end
success_count = results.count { |r| r[:status] == :ok }
Rails.logger.info("Batch complete: #{success_count}/#{results.size} invoices generated")
For detailed batch patterns and queue-based processing, see the PDF API Batch Processing Guide.
Error Handling
The service object pattern above raises PdfGenerator::PdfGenerationError on failure. For higher-level retry logic outside of Faraday's built-in middleware:
# lib/pdf_generator_with_circuit_breaker.rb
class PdfGeneratorWithCircuitBreaker
MAX_FAILURES = 5
OPEN_DURATION = 60 # seconds
def initialize
@generator = PdfGenerator.new
@failures = 0
@circuit_open = false
@opened_at = nil
@mutex = Mutex.new
end
def from_html(html, **opts)
@mutex.synchronize do
if @circuit_open
if Time.current - @opened_at > OPEN_DURATION
# Half-open: try one request
@circuit_open = false
@failures = 0
else
raise PdfGenerator::PdfGenerationError.new(503, 'Circuit breaker open')
end
end
end
result = @generator.from_html(html, **opts)
@mutex.synchronize { @failures = 0 }
result
rescue PdfGenerator::PdfGenerationError => e
@mutex.synchronize do
@failures += 1
if @failures >= MAX_FAILURES
@circuit_open = true
@opened_at = Time.current
Rails.logger.warn("PDF circuit breaker opened after #{@failures} failures")
end
end
raise
end
end
For full error handling patterns including retry strategies, see the PDF API Error Handling Guide.
Secure API Key Management
Never hardcode your API key. In Rails, use credentials or environment variables:
# .env (never commit to Git)
FUNBREW_PDF_API_KEY=sk-live-abc123
# config/credentials.yml.enc (encrypted, safe to commit)
# Access via: Rails.application.credentials.funbrew_pdf_api_key
funbrew_pdf_api_key: sk-live-abc123
# Initializer — choose one source
api_key = ENV['FUNBREW_PDF_API_KEY'] ||
Rails.application.credentials.funbrew_pdf_api_key
generator = PdfGenerator.new(api_key: api_key)
For production security practices including IP whitelisting and SSRF prevention, see the PDF API Security Guide.
Testing
Use WebMock or VCR to avoid hitting the real API in tests.
# spec/services/pdf_generator_spec.rb
require 'webmock/rspec'
RSpec.describe PdfGenerator do
let(:generator) { described_class.new(api_key: 'test-key') }
let(:fake_pdf) { '%PDF-1.4 fake'.b }
before do
stub_request(:post, 'https://api.pdf.funbrew.cloud/v1/pdf/from-html')
.with(
headers: { 'Authorization' => 'Bearer test-key' },
body: hash_including('html' => '<h1>Test</h1>')
)
.to_return(status: 200, body: fake_pdf, headers: { 'Content-Type' => 'application/pdf' })
end
it 'returns PDF bytes on success' do
result = generator.from_html('<h1>Test</h1>')
expect(result).to eq(fake_pdf)
end
context 'when the API returns 429' do
before do
stub_request(:post, 'https://api.pdf.funbrew.cloud/v1/pdf/from-html')
.to_return(status: 429, body: '{"error":"rate_limited"}')
end
it 'raises PdfGenerationError' do
expect { generator.from_html('<h1>Test</h1>') }
.to raise_error(PdfGenerator::PdfGenerationError) do |err|
expect(err.status).to eq(429)
expect(err.retryable?).to be true
end
end
end
end
Production Checklist
Before deploying to production, verify:
| Item | Detail |
|---|---|
| API key in env vars | Never in source code or Rails credentials committed unencrypted |
| Faraday retry configured | retry_statuses: [429, 500, 502, 503] with backoff |
| Sidekiq queue isolated | Separate pdf queue so PDF backlog doesn't block critical jobs |
| Timeout set | At least 60 s for complex HTML; Sidekiq job timeout aligned |
| Error logging | PdfGenerationError captured in Sentry/Bugsnag with invoice ID |
| Active Storage or S3 | Generated PDFs stored externally, not in Rails tmp |
| IP allowlisting | Configure production server IPs in FUNBREW PDF dashboard |
For the full production checklist covering monitoring, scaling, and cost management, see the PDF API Production Guide.
Summary
Ruby's strengths — Faraday's middleware chain, Sidekiq's reliable async processing, Rails' conventions — make it straightforward to build a robust PDF generation pipeline:
- Service object: One class per integration, injectable and testable
- Faraday retry: Automatic backoff on 429/5xx without manual retry loops
- Sidekiq worker: Move generation off the request thread with reliable retries
- Batch with Parallel: Thread-safe concurrent generation for bulk jobs
- Circuit breaker: Prevent cascade failures during API outages
Related
- Ruby and Go Quickstart — Basic Ruby and Go code examples with net/http and Faraday
- Go PDF API Guide — Goroutine worker pools and production patterns for Go
- PDF API Batch Processing Guide — Queue-based patterns for large-scale generation
- PDF API Error Handling Guide — Error types, retry strategies, and circuit breakers
- PDF API Security Guide — API key management, IP whitelisting, and SSRF prevention
- PDF API Production Guide — Monitoring, scaling, and cost management
- API Documentation — Full FUNBREW PDF option reference
- Playground — Test HTML-to-PDF in the browser before writing code