Invalid Date

When generating PDFs in Java, developers typically reach for iText, Apache PDFBox, or JasperReports. However, iText 8 is licensed under AGPLv3, which requires commercial projects that distribute the software as a service to purchase a paid license (often hundreds of dollars per year). PDFBox is freely available but struggles to accurately reproduce complex HTML layouts, and embedding Japanese fonts is tedious.

FUNBREW PDF API lets you generate high-quality PDFs by sending an HTTP request with HTML. Zero license cost, reduced server resource usage, built-in Japanese font support — and integration with Spring Boot takes fewer than a hundred lines of code.

This guide walks you through integrating FUNBREW PDF API into a Spring Boot application using both RestTemplate and WebClient. We cover error handling, async processing, Thymeleaf templates, and production best practices.

For other languages and frameworks, see the language quickstart guide. For error handling patterns, see the error handling guide.

Why API-Based PDF Generation?

The iText 8 License Problem

iText 8 core is licensed under AGPLv3. The AGPL "network use is distribution" clause means that if you offer iText-powered PDF generation as a service to end users, you must either open-source your entire application or purchase a Commercial License from iText Group. For proprietary SaaS products, that commercial license can cost significantly.

Library License Commercial Constraint
iText 8 AGPLv3 / Commercial Paid license required for proprietary products
Apache PDFBox Apache License 2.0 No restriction (poor HTML rendering)
JasperReports LGPL / Commercial Template-based; HTML conversion is separate
FUNBREW PDF API SaaS (subscription) No license issues; native HTML support

Server Resource Savings

PDF generation with iText or PDFBox runs on your application server, consuming CPU and heap memory per document. JasperReports under heavy load is particularly prone to heap pressure, often requiring careful JVM tuning. In containerized environments, these libraries also inflate your Docker image size.

With an API-based approach, the rendering work is offloaded to an external service. Your application server only issues HTTP requests, keeping instance sizes smaller and eliminating dedicated PDF generation pods in Kubernetes.

Japanese Font Support

Generating Japanese PDFs with iText or PDFBox requires bundling fonts like Noto Sans JP, configuring font loading, and handling CJK glyph rendering carefully. FUNBREW PDF comes with Noto Sans JP pre-installed — you simply set font-family: 'Noto Sans JP' in your CSS and Japanese renders correctly.

Spring Boot Project Setup

Dependencies (pom.xml)

This guide targets Spring Boot 3.x (Spring Framework 6.x). WebClient requires the spring-boot-starter-webflux dependency.

<dependencies>
  <!-- Web (for RestTemplate) -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>

  <!-- WebFlux (for WebClient) -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
  </dependency>

  <!-- Configuration processor -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
  </dependency>

  <!-- Testing -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>mockwebserver</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

For Gradle users:

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'com.squareup.okhttp3:mockwebserver'
}

Application Configuration

Load the API key from an environment variable — never hardcode it in source files.

# application.properties
funbrew.pdf.api-key=${FUNBREW_PDF_API_KEY}
funbrew.pdf.api-url=https://api.pdf.funbrew.cloud/v1/pdf/from-html
funbrew.pdf.timeout-seconds=60
# application.yml
funbrew:
  pdf:
    api-key: ${FUNBREW_PDF_API_KEY}
    api-url: https://api.pdf.funbrew.cloud/v1/pdf/from-html
    timeout-seconds: 60

Configuration Properties Class

Use @ConfigurationProperties for type-safe property binding.

// src/main/java/com/example/pdf/config/FunbrewPdfProperties.java
package com.example.pdf.config;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "funbrew.pdf")
public class FunbrewPdfProperties {

    private String apiKey;
    private String apiUrl = "https://api.pdf.funbrew.cloud/v1/pdf/from-html";
    private int timeoutSeconds = 60;

    public String getApiKey() { return apiKey; }
    public void setApiKey(String apiKey) { this.apiKey = apiKey; }

    public String getApiUrl() { return apiUrl; }
    public void setApiUrl(String apiUrl) { this.apiUrl = apiUrl; }

    public int getTimeoutSeconds() { return timeoutSeconds; }
    public void setTimeoutSeconds(int timeoutSeconds) { this.timeoutSeconds = timeoutSeconds; }
}

Add @ConfigurationPropertiesScan to your main application class:

// src/main/java/com/example/pdf/PdfApplication.java
package com.example.pdf;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@SpringBootApplication
@ConfigurationPropertiesScan
public class PdfApplication {
    public static void main(String[] args) {
        SpringApplication.run(PdfApplication.class, args);
    }
}

Calling the API with RestTemplate

RestTemplate is the synchronous HTTP client. It works well for existing Spring MVC applications or cases where simple synchronous processing is sufficient. While RestTemplate is in maintenance mode in Spring Boot 3.x (with WebClient as the recommended replacement), it remains fully supported.

PdfRequest DTO

// src/main/java/com/example/pdf/dto/PdfRequest.java
package com.example.pdf.dto;

import com.fasterxml.jackson.annotation.JsonProperty;

public class PdfRequest {

    private String html;
    private String format = "A4";
    private String engine = "quality";

    @JsonProperty("landscape")
    private boolean landscape = false;

    public PdfRequest(String html) {
        this.html = html;
    }

    public PdfRequest(String html, String format, String engine) {
        this.html = html;
        this.format = format;
        this.engine = engine;
    }

    public String getHtml() { return html; }
    public String getFormat() { return format; }
    public String getEngine() { return engine; }
    public boolean isLandscape() { return landscape; }

    public void setHtml(String html) { this.html = html; }
    public void setFormat(String format) { this.format = format; }
    public void setEngine(String engine) { this.engine = engine; }
    public void setLandscape(boolean landscape) { this.landscape = landscape; }
}

RestTemplate Bean

// src/main/java/com/example/pdf/config/PdfClientConfig.java
package com.example.pdf.config;

import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

import java.time.Duration;

@Configuration
public class PdfClientConfig {

    @Bean
    public RestTemplate pdfRestTemplate(
            RestTemplateBuilder builder,
            FunbrewPdfProperties props) {
        return builder
                .connectTimeout(Duration.ofSeconds(10))
                .readTimeout(Duration.ofSeconds(props.getTimeoutSeconds()))
                .build();
    }
}

PdfService (RestTemplate)

// src/main/java/com/example/pdf/service/PdfService.java
package com.example.pdf.service;

import com.example.pdf.config.FunbrewPdfProperties;
import com.example.pdf.dto.PdfRequest;
import com.example.pdf.exception.PdfGenerationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestTemplate;

import java.util.Collections;

@Service
public class PdfService {

    private static final Logger log = LoggerFactory.getLogger(PdfService.class);

    private final RestTemplate restTemplate;
    private final FunbrewPdfProperties props;

    public PdfService(RestTemplate pdfRestTemplate, FunbrewPdfProperties props) {
        this.restTemplate = pdfRestTemplate;
        this.props = props;
    }

    /**
     * Generate a PDF from an HTML string.
     *
     * @param html HTML string to convert
     * @return PDF binary as byte[]
     * @throws PdfGenerationException if the API call fails
     */
    public byte[] generateFromHtml(String html) {
        return generateFromHtml(html, "A4", "quality");
    }

    public byte[] generateFromHtml(String html, String format, String engine) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.setAccept(Collections.singletonList(MediaType.APPLICATION_OCTET_STREAM));
        headers.setBearerAuth(props.getApiKey());

        PdfRequest request = new PdfRequest(html, format, engine);
        HttpEntity<PdfRequest> entity = new HttpEntity<>(request, headers);

        try {
            ResponseEntity<byte[]> response = restTemplate.exchange(
                    props.getApiUrl(),
                    HttpMethod.POST,
                    entity,
                    byte[].class
            );
            log.debug("PDF generated: {} bytes",
                    response.getBody() != null ? response.getBody().length : 0);
            return response.getBody();
        } catch (HttpClientErrorException e) {
            log.error("PDF API client error: {} {}", e.getStatusCode(), e.getResponseBodyAsString());
            throw new PdfGenerationException(
                    "PDF generation request rejected: " + e.getStatusCode(), e);
        } catch (HttpServerErrorException e) {
            log.error("PDF API server error: {} {}", e.getStatusCode(), e.getResponseBodyAsString());
            throw new PdfGenerationException(
                    "PDF API server error: " + e.getStatusCode(), e);
        }
    }
}

Custom Exception

// src/main/java/com/example/pdf/exception/PdfGenerationException.java
package com.example.pdf.exception;

public class PdfGenerationException extends RuntimeException {

    public PdfGenerationException(String message) {
        super(message);
    }

    public PdfGenerationException(String message, Throwable cause) {
        super(message, cause);
    }
}

Calling the API with WebClient

WebClient is the reactive HTTP client from Spring WebFlux. It uses non-blocking I/O, freeing processing threads during the API call. For new Spring Boot 3.x projects, WebClient is the recommended choice.

WebClient Bean

// src/main/java/com/example/pdf/config/PdfClientConfig.java (WebClient version)
package com.example.pdf.config;

import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;

import java.time.Duration;
import java.util.concurrent.TimeUnit;

@Configuration
public class PdfClientConfig {

    @Bean
    public WebClient pdfWebClient(FunbrewPdfProperties props) {
        HttpClient httpClient = HttpClient.create()
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10_000)
                .responseTimeout(Duration.ofSeconds(props.getTimeoutSeconds()))
                .doOnConnected(conn ->
                        conn.addHandlerLast(
                                new ReadTimeoutHandler(props.getTimeoutSeconds(), TimeUnit.SECONDS)));

        return WebClient.builder()
                .baseUrl(props.getApiUrl())
                .defaultHeader("Authorization", "Bearer " + props.getApiKey())
                .defaultHeader("Content-Type", "application/json")
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .build();
    }
}

PdfReactiveService (WebClient)

// src/main/java/com/example/pdf/service/PdfReactiveService.java
package com.example.pdf.service;

import com.example.pdf.dto.PdfRequest;
import com.example.pdf.exception.PdfGenerationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;

@Service
public class PdfReactiveService {

    private static final Logger log = LoggerFactory.getLogger(PdfReactiveService.class);

    private final WebClient webClient;

    public PdfReactiveService(WebClient pdfWebClient) {
        this.webClient = pdfWebClient;
    }

    /**
     * Asynchronously generate a PDF from an HTML string.
     *
     * @param html HTML string to convert
     * @return Mono<byte[]> that emits the PDF binary
     */
    public Mono<byte[]> generateFromHtml(String html) {
        return generateFromHtml(html, "A4", "quality");
    }

    public Mono<byte[]> generateFromHtml(String html, String format, String engine) {
        PdfRequest request = new PdfRequest(html, format, engine);

        return webClient.post()
                .contentType(MediaType.APPLICATION_JSON)
                .accept(MediaType.APPLICATION_OCTET_STREAM)
                .bodyValue(request)
                .retrieve()
                .bodyToMono(byte[].class)
                .doOnSuccess(bytes ->
                        log.debug("PDF generated: {} bytes",
                                bytes != null ? bytes.length : 0))
                .onErrorMap(WebClientResponseException.class, e -> {
                    log.error("PDF API error: {} {}", e.getStatusCode(),
                            e.getResponseBodyAsString());
                    return new PdfGenerationException(
                            "PDF generation failed: HTTP " + e.getStatusCode().value(), e);
                });
    }
}

HTML-to-PDF Implementation Examples

Invoice PDF with Thymeleaf

Thymeleaf is the standard templating engine in Spring Boot. Rendering a Thymeleaf template to HTML and then sending that HTML to the PDF API is the most natural pattern.

<!-- Add to pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
// src/main/java/com/example/pdf/service/InvoicePdfService.java
package com.example.pdf.service;

import com.example.pdf.model.Invoice;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;

import java.util.Locale;

@Service
public class InvoicePdfService {

    private final TemplateEngine templateEngine;
    private final PdfService pdfService;

    public InvoicePdfService(TemplateEngine templateEngine, PdfService pdfService) {
        this.templateEngine = templateEngine;
        this.pdfService = pdfService;
    }

    /**
     * Generate a PDF from an Invoice model.
     *
     * @param invoice invoice data
     * @return PDF binary
     */
    public byte[] generateInvoicePdf(Invoice invoice) {
        Context context = new Context(Locale.ENGLISH);
        context.setVariable("invoice", invoice);

        // Render Thymeleaf template to HTML
        String html = templateEngine.process("pdf/invoice", context);

        // Send HTML to PDF API
        return pdfService.generateFromHtml(html);
    }
}
<!-- src/main/resources/templates/pdf/invoice.html -->
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      font-family: -apple-system, 'Helvetica Neue', Arial, sans-serif;
      font-size: 14px;
      color: #1a1a1a;
      padding: 48px;
      line-height: 1.6;
    }
    .header {
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      border-bottom: 3px solid #1a56db;
      padding-bottom: 20px;
      margin-bottom: 32px;
    }
    .header h1 {
      font-size: 28px;
      font-weight: 700;
      color: #1a56db;
    }
    .meta-box {
      text-align: right;
      font-size: 13px;
      color: #6b7280;
    }
    .meta-box p { margin-top: 4px; }
    .section { margin-bottom: 24px; }
    .section-title {
      font-size: 12px;
      font-weight: 700;
      color: #6b7280;
      text-transform: uppercase;
      letter-spacing: 0.05em;
      margin-bottom: 8px;
    }
    table {
      width: 100%;
      border-collapse: collapse;
      margin-top: 24px;
    }
    th {
      background: #f3f4f6;
      padding: 12px 16px;
      text-align: left;
      font-size: 12px;
      font-weight: 700;
      color: #374151;
      border-bottom: 2px solid #e5e7eb;
    }
    td {
      padding: 12px 16px;
      border-bottom: 1px solid #e5e7eb;
      color: #374151;
    }
    .amount { text-align: right; }
    .total-section {
      margin-top: 24px;
      text-align: right;
    }
    .total-row {
      display: flex;
      justify-content: flex-end;
      gap: 48px;
      padding: 8px 0;
      font-size: 14px;
    }
    .total-row.grand-total {
      font-size: 20px;
      font-weight: 700;
      color: #1a56db;
      border-top: 2px solid #1a56db;
      padding-top: 16px;
      margin-top: 8px;
    }
    .footer {
      margin-top: 48px;
      padding-top: 16px;
      border-top: 1px solid #e5e7eb;
      font-size: 12px;
      color: #9ca3af;
      text-align: center;
    }
  </style>
</head>
<body>

  <div class="header">
    <h1>Invoice</h1>
    <div class="meta-box">
      <p><strong>Invoice #:</strong> <span th:text="${invoice.invoiceNumber}"></span></p>
      <p><strong>Issue Date:</strong> <span th:text="${invoice.issueDate}"></span></p>
      <p><strong>Due Date:</strong> <span th:text="${invoice.dueDate}"></span></p>
    </div>
  </div>

  <div class="section">
    <div class="section-title">Bill To</div>
    <p th:text="${invoice.customerName}"></p>
    <p th:text="${invoice.customerAddress}" style="color: #6b7280; font-size: 13px;"></p>
  </div>

  <div class="section">
    <div class="section-title">From</div>
    <p th:text="${invoice.companyName}"></p>
    <p th:text="${invoice.companyAddress}" style="color: #6b7280; font-size: 13px;"></p>
  </div>

  <table>
    <thead>
      <tr>
        <th>Item</th>
        <th>Qty</th>
        <th class="amount">Unit Price</th>
        <th class="amount">Subtotal</th>
      </tr>
    </thead>
    <tbody>
      <tr th:each="item : ${invoice.items}">
        <td th:text="${item.name}"></td>
        <td th:text="${item.quantity}"></td>
        <td class="amount" th:text="'$' + ${#numbers.formatDecimal(item.unitPrice, 1, 'COMMA', 2, 'POINT')}"></td>
        <td class="amount" th:text="'$' + ${#numbers.formatDecimal(item.quantity * item.unitPrice, 1, 'COMMA', 2, 'POINT')}"></td>
      </tr>
    </tbody>
  </table>

  <div class="total-section">
    <div class="total-row">
      <span>Subtotal</span>
      <span th:text="'$' + ${#numbers.formatDecimal(invoice.subtotal, 1, 'COMMA', 2, 'POINT')}"></span>
    </div>
    <div class="total-row">
      <span>Tax (10%)</span>
      <span th:text="'$' + ${#numbers.formatDecimal(invoice.tax, 1, 'COMMA', 2, 'POINT')}"></span>
    </div>
    <div class="total-row grand-total">
      <span>Total</span>
      <span th:text="'$' + ${#numbers.formatDecimal(invoice.total, 1, 'COMMA', 2, 'POINT')}"></span>
    </div>
  </div>

  <div class="footer">
    <p>Questions? Contact us at billing@example.com</p>
  </div>

</body>
</html>

Invoice Model

// src/main/java/com/example/pdf/model/Invoice.java
package com.example.pdf.model;

import java.util.List;

public class Invoice {

    private String invoiceNumber;
    private String issueDate;
    private String dueDate;
    private String customerName;
    private String customerAddress;
    private String companyName;
    private String companyAddress;
    private List<InvoiceItem> items;

    public double getSubtotal() {
        return items.stream()
                .mapToDouble(item -> item.quantity() * item.unitPrice())
                .sum();
    }

    public double getTax() {
        return Math.round(getSubtotal() * 0.1 * 100.0) / 100.0;
    }

    public double getTotal() {
        return getSubtotal() + getTax();
    }

    public static Builder builder() { return new Builder(); }

    public static class Builder {
        private final Invoice invoice = new Invoice();

        public Builder invoiceNumber(String n) { invoice.invoiceNumber = n; return this; }
        public Builder issueDate(String d) { invoice.issueDate = d; return this; }
        public Builder dueDate(String d) { invoice.dueDate = d; return this; }
        public Builder customerName(String n) { invoice.customerName = n; return this; }
        public Builder customerAddress(String a) { invoice.customerAddress = a; return this; }
        public Builder companyName(String n) { invoice.companyName = n; return this; }
        public Builder companyAddress(String a) { invoice.companyAddress = a; return this; }
        public Builder items(List<InvoiceItem> items) { invoice.items = items; return this; }
        public Invoice build() { return invoice; }
    }

    public String getInvoiceNumber() { return invoiceNumber; }
    public String getIssueDate() { return issueDate; }
    public String getDueDate() { return dueDate; }
    public String getCustomerName() { return customerName; }
    public String getCustomerAddress() { return customerAddress; }
    public String getCompanyName() { return companyName; }
    public String getCompanyAddress() { return companyAddress; }
    public List<InvoiceItem> getItems() { return items; }
}
// src/main/java/com/example/pdf/model/InvoiceItem.java
package com.example.pdf.model;

public record InvoiceItem(String name, int quantity, double unitPrice) {}

REST Controller

// src/main/java/com/example/pdf/controller/InvoiceController.java
package com.example.pdf.controller;

import com.example.pdf.model.Invoice;
import com.example.pdf.model.InvoiceItem;
import com.example.pdf.service.InvoicePdfService;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {

    private final InvoicePdfService invoicePdfService;

    public InvoiceController(InvoicePdfService invoicePdfService) {
        this.invoicePdfService = invoicePdfService;
    }

    @PostMapping("/{invoiceId}/pdf")
    public ResponseEntity<byte[]> downloadInvoicePdf(@PathVariable String invoiceId) {
        Invoice invoice = buildSampleInvoice(invoiceId);

        byte[] pdfBytes = invoicePdfService.generateInvoicePdf(invoice);

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_PDF);
        headers.setContentDisposition(
                ContentDisposition.attachment()
                        .filename("invoice-" + invoiceId + ".pdf")
                        .build()
        );
        headers.setContentLength(pdfBytes.length);

        return new ResponseEntity<>(pdfBytes, headers, HttpStatus.OK);
    }

    private Invoice buildSampleInvoice(String invoiceId) {
        return Invoice.builder()
                .invoiceNumber(invoiceId)
                .issueDate("2026-04-05")
                .dueDate("2026-04-30")
                .customerName("Acme Corp")
                .customerAddress("123 Main St, San Francisco, CA 94105")
                .companyName("FUNBREW Inc.")
                .companyAddress("456 Market St, San Francisco, CA 94105")
                .items(List.of(
                        new InvoiceItem("FUNBREW PDF API Plan", 1, 99.00),
                        new InvoiceItem("Additional API calls (500)", 1, 20.00)
                ))
                .build();
    }
}

Error Handling

Global Exception Handler

Use @ControllerAdvice with Spring Boot 3's ProblemDetail (RFC 7807) for standardized error responses.

// src/main/java/com/example/pdf/exception/GlobalExceptionHandler.java
package com.example.pdf.exception;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.net.URI;

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(PdfGenerationException.class)
    public ProblemDetail handlePdfGenerationException(PdfGenerationException ex) {
        log.error("PDF generation failed", ex);
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_GATEWAY, ex.getMessage());
        problem.setTitle("PDF Generation Failed");
        problem.setType(URI.create("https://pdf.funbrew.cloud/errors/pdf-generation-failed"));
        return problem;
    }
}

Error responses follow the RFC 7807 format:

{
  "type": "https://pdf.funbrew.cloud/errors/pdf-generation-failed",
  "title": "PDF Generation Failed",
  "status": 502,
  "detail": "PDF API server error: HTTP 503"
}

Retry with Exponential Backoff (Spring Retry)

Add Spring Retry for automatic retries on transient failures:

<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-aspects</artifactId>
</dependency>

Enable retry in the main class:

@SpringBootApplication
@ConfigurationPropertiesScan
@EnableRetry
public class PdfApplication { ... }

Annotate the service method:

import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.web.client.HttpServerErrorException;

@Retryable(
    retryFor = { HttpServerErrorException.class, PdfGenerationException.class },
    maxAttempts = 3,
    backoff = @Backoff(delay = 1000, multiplier = 2.0)
)
public byte[] generateFromHtml(String html) {
    // existing implementation
}

With maxAttempts = 3, delay = 1000ms, and multiplier = 2.0, this retries at 1 second, then 2 seconds — matching typical API rate limit recovery windows.

Timeout and Rate Limit Handling

public byte[] generateFromHtml(String html, String format, String engine) {
    try {
        // RestTemplate call...
    } catch (ResourceAccessException e) {
        if (e.getCause() instanceof SocketTimeoutException) {
            log.error("PDF API timed out after {} seconds", props.getTimeoutSeconds());
            throw new PdfGenerationException("PDF generation timed out. Please try again.", e);
        }
        throw new PdfGenerationException("Failed to connect to PDF API.", e);
    } catch (HttpClientErrorException e) {
        if (e.getStatusCode().value() == 429) {
            log.warn("PDF API rate limit exceeded");
            throw new PdfGenerationException(
                    "API rate limit reached. Please wait before retrying.", e);
        }
        throw new PdfGenerationException(
                "PDF generation request rejected: " + e.getStatusCode(), e);
    } catch (HttpServerErrorException e) {
        log.error("PDF API server error: {}", e.getStatusCode());
        throw new PdfGenerationException("PDF API server error occurred.", e);
    }
}

For a full list of error codes and retry strategies, see the error handling guide.

Async Processing with @Async

For background PDF generation or bulk document creation, Spring's @Async prevents blocking web request threads.

Async Configuration

// src/main/java/com/example/pdf/config/AsyncConfig.java
package com.example.pdf.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "pdfTaskExecutor")
    public Executor pdfTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("pdf-");
        executor.initialize();
        return executor;
    }
}

Async PDF Service

// src/main/java/com/example/pdf/service/AsyncPdfService.java
package com.example.pdf.service;

import com.example.pdf.model.Invoice;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CompletableFuture;

@Service
public class AsyncPdfService {

    private static final Logger log = LoggerFactory.getLogger(AsyncPdfService.class);

    private final InvoicePdfService invoicePdfService;

    public AsyncPdfService(InvoicePdfService invoicePdfService) {
        this.invoicePdfService = invoicePdfService;
    }

    /**
     * Asynchronously generate a PDF and save it to disk.
     */
    @Async("pdfTaskExecutor")
    public CompletableFuture<Path> generateAndSave(Invoice invoice, Path outputPath) {
        try {
            log.info("Generating PDF for invoice: {}", invoice.getInvoiceNumber());
            byte[] pdfBytes = invoicePdfService.generateInvoicePdf(invoice);

            Files.createDirectories(outputPath.getParent());
            Files.write(outputPath, pdfBytes);

            log.info("PDF saved: {} ({} bytes)", outputPath, pdfBytes.length);
            return CompletableFuture.completedFuture(outputPath);
        } catch (IOException e) {
            log.error("Failed to save PDF: {}", outputPath, e);
            CompletableFuture<Path> failed = new CompletableFuture<>();
            failed.completeExceptionally(e);
            return failed;
        }
    }

    /**
     * Generate multiple PDFs in parallel.
     */
    public CompletableFuture<Void> generateBatch(List<Invoice> invoices, Path outputDir) {
        CompletableFuture<?>[] futures = invoices.stream()
                .map(invoice -> {
                    Path outputPath = outputDir.resolve(
                            "invoice-" + invoice.getInvoiceNumber() + ".pdf");
                    return generateAndSave(invoice, outputPath);
                })
                .toArray(CompletableFuture[]::new);

        return CompletableFuture.allOf(futures);
    }
}

Async Endpoint

@PostMapping("/{invoiceId}/pdf/async")
public ResponseEntity<Map<String, String>> requestPdfAsync(@PathVariable String invoiceId) {
    Invoice invoice = buildSampleInvoice(invoiceId);
    Path outputPath = Paths.get("storage/invoices", "invoice-" + invoiceId + ".pdf");

    asyncPdfService.generateAndSave(invoice, outputPath)
            .whenComplete((path, ex) -> {
                if (ex != null) {
                    log.error("Async PDF generation failed: {}", invoiceId, ex);
                } else {
                    log.info("Async PDF ready: {}", path);
                    // Trigger webhook notification or update DB status
                }
            });

    return ResponseEntity.accepted().body(Map.of(
            "status", "processing",
            "invoiceId", invoiceId,
            "message", "PDF generation started. You will be notified when complete."
    ));
}

For batch processing at scale, see the batch processing guide.

Testing

Unit Tests with MockRestServiceServer (RestTemplate)

Spring's MockRestServiceServer lets you mock HTTP calls without a real server:

// src/test/java/com/example/pdf/service/PdfServiceTest.java
package com.example.pdf.service;

import com.example.pdf.config.FunbrewPdfProperties;
import com.example.pdf.exception.PdfGenerationException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestTemplate;

import static org.assertj.core.api.Assertions.*;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
import static org.springframework.test.web.client.response.MockRestResponseCreators.*;

@SpringBootTest
class PdfServiceTest {

    @Autowired
    private RestTemplate pdfRestTemplate;

    @Autowired
    private PdfService pdfService;

    @Autowired
    private FunbrewPdfProperties props;

    private MockRestServiceServer mockServer;

    @BeforeEach
    void setUp() {
        mockServer = MockRestServiceServer.createServer(pdfRestTemplate);
    }

    @Test
    void generateFromHtml_Success_ReturnsPdfBytes() {
        byte[] fakePdf = "%PDF-1.4 fake content".getBytes();
        mockServer.expect(requestTo(props.getApiUrl()))
                .andExpect(method(HttpMethod.POST))
                .andExpect(header("Authorization", "Bearer " + props.getApiKey()))
                .andRespond(withSuccess(fakePdf, MediaType.APPLICATION_OCTET_STREAM));

        byte[] result = pdfService.generateFromHtml("<h1>Test</h1>");

        assertThat(result).isEqualTo(fakePdf);
        mockServer.verify();
    }

    @Test
    void generateFromHtml_ServerError_ThrowsPdfGenerationException() {
        mockServer.expect(requestTo(props.getApiUrl()))
                .andRespond(withServerError());

        assertThatThrownBy(() -> pdfService.generateFromHtml("<h1>Test</h1>"))
                .isInstanceOf(PdfGenerationException.class)
                .hasMessageContaining("500");

        mockServer.verify();
    }

    @Test
    void generateFromHtml_RateLimit_ThrowsWithRateLimitMessage() {
        mockServer.expect(requestTo(props.getApiUrl()))
                .andRespond(withStatus(org.springframework.http.HttpStatus.TOO_MANY_REQUESTS));

        assertThatThrownBy(() -> pdfService.generateFromHtml("<h1>Test</h1>"))
                .isInstanceOf(PdfGenerationException.class)
                .hasMessageContaining("rate limit");

        mockServer.verify();
    }
}

Unit Tests with MockWebServer (WebClient)

For WebClient, use OkHttp's MockWebServer:

// src/test/java/com/example/pdf/service/PdfReactiveServiceTest.java
package com.example.pdf.service;

import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.test.StepVerifier;

import java.io.IOException;

import static org.assertj.core.api.Assertions.assertThat;

class PdfReactiveServiceTest {

    private MockWebServer mockWebServer;
    private PdfReactiveService pdfReactiveService;

    @BeforeEach
    void setUp() throws IOException {
        mockWebServer = new MockWebServer();
        mockWebServer.start();

        WebClient webClient = WebClient.builder()
                .baseUrl(mockWebServer.url("/").toString())
                .defaultHeader("Authorization", "Bearer sk-test")
                .build();

        pdfReactiveService = new PdfReactiveService(webClient);
    }

    @AfterEach
    void tearDown() throws IOException {
        mockWebServer.shutdown();
    }

    @Test
    void generateFromHtml_Success_EmitsPdfBytes() {
        byte[] fakePdf = "%PDF-1.4 fake".getBytes();
        mockWebServer.enqueue(new MockResponse()
                .setBody(new okio.Buffer().write(fakePdf))
                .addHeader("Content-Type", "application/octet-stream")
                .setResponseCode(200));

        StepVerifier.create(pdfReactiveService.generateFromHtml("<h1>Test</h1>"))
                .assertNext(result -> assertThat(result).isEqualTo(fakePdf))
                .verifyComplete();
    }

    @Test
    void generateFromHtml_ServerError_ThrowsException() {
        mockWebServer.enqueue(new MockResponse().setResponseCode(500));

        StepVerifier.create(pdfReactiveService.generateFromHtml("<h1>Test</h1>"))
                .expectErrorMatches(ex ->
                        ex instanceof com.example.pdf.exception.PdfGenerationException
                        && ex.getMessage().contains("500"))
                .verify();
    }
}

Production Tips

API Key Security

Never hardcode API keys in source files or commit them to version control.

# Environment variable (recommended for most deployments)
export FUNBREW_PDF_API_KEY=sk-your-production-key

# Kubernetes Secret
kubectl create secret generic funbrew-pdf-secret \
  --from-literal=api-key=sk-your-production-key
# Reference in Kubernetes Deployment
env:
  - name: FUNBREW_PDF_API_KEY
    valueFrom:
      secretKeyRef:
        name: funbrew-pdf-secret
        key: api-key

For integration with Spring Cloud Vault or AWS Secrets Manager, see the security guide.

PDF Caching

If you generate the same PDF repeatedly (e.g., static reports), cache by HTML content hash:

// src/main/java/com/example/pdf/service/CachedPdfService.java
package com.example.pdf.service;

import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;

@Service
public class CachedPdfService {

    private final PdfService pdfService;

    public CachedPdfService(PdfService pdfService) {
        this.pdfService = pdfService;
    }

    @Cacheable(value = "pdf-cache", key = "#root.target.hashHtml(#html)")
    public byte[] generateWithCache(String html) {
        return pdfService.generateFromHtml(html);
    }

    public String hashHtml(String html) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hashBytes = digest.digest(html.getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(hashBytes);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }
}
# application.yml — Caffeine cache
spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=200,expireAfterWrite=3600s

Actuator Metrics

Track PDF generation latency and error rate with Micrometer:

@Service
public class PdfService {

    private final Timer generateTimer;
    private final Counter errorCounter;

    public PdfService(RestTemplate pdfRestTemplate,
                      FunbrewPdfProperties props,
                      MeterRegistry meterRegistry) {
        this.restTemplate = pdfRestTemplate;
        this.props = props;
        this.generateTimer = Timer.builder("pdf.generation.time")
                .description("PDF generation latency")
                .register(meterRegistry);
        this.errorCounter = Counter.builder("pdf.generation.errors")
                .description("PDF generation error count")
                .register(meterRegistry);
    }

    public byte[] generateFromHtml(String html) {
        return generateTimer.record(() -> {
            try {
                return generateFromHtml(html, "A4", "quality");
            } catch (PdfGenerationException e) {
                errorCounter.increment();
                throw e;
            }
        });
    }
}

Connection Pool Tuning

For high-traffic environments, configure the HTTP connection pool:

// Apache HttpClient 5 with connection pooling
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;

@Bean
public RestTemplate pdfRestTemplate(FunbrewPdfProperties props) {
    PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
    cm.setMaxTotal(50);
    cm.setDefaultMaxPerRoute(20);

    CloseableHttpClient httpClient = HttpClients.custom()
            .setConnectionManager(cm)
            .build();

    HttpComponentsClientHttpRequestFactory factory =
            new HttpComponentsClientHttpRequestFactory(httpClient);
    factory.setConnectTimeout(10_000);
    factory.setReadTimeout((int) (props.getTimeoutSeconds() * 1000L));

    return new RestTemplate(factory);
}

For Docker and Kubernetes deployment patterns, see the Docker & Kubernetes guide. For comprehensive production checklists, see the production guide.

RestTemplate vs WebClient: Which to Choose?

Aspect RestTemplate WebClient
Processing model Synchronous (blocks thread) Async / reactive (non-blocking)
Spring Boot status Maintenance mode (for existing code) Recommended for new projects
Learning curve Low (simple API) Higher (requires Reactor knowledge)
Testing MockRestServiceServer (built-in) MockWebServer (okhttp3)
Scalability Bounded by thread pool size High throughput via event loop
MVC integration Natural fit Requires block() — deadlock risk in MVC

Decision guide:

  • Adding PDF generation to an existing Spring MVC app — RestTemplate is the pragmatic choice.
  • New Spring WebFlux application — Use WebClient and stay reactive end-to-end.
  • High-concurrency PDF generation (tens per second or more) — WebClient with reactive pipelines.
  • Background batch PDF generation@Async + CompletableFuture works with either.

Summary

Integrating FUNBREW PDF API into Spring Boot is straightforward. You escape iText's AGPL licensing constraints, eliminate server-side rendering overhead, and generate professional PDFs from Thymeleaf templates with a single HTTP call.

  1. No license costs — No iText commercial license required
  2. Japanese font ready — Noto Sans JP is pre-installed
  3. Reduced server load — PDF rendering is offloaded to FUNBREW
  4. Simple integration — RestTemplate or WebClient + Thymeleaf
  5. Production-ready — Retry, caching, and metrics patterns included

Try your HTML templates in the Playground to see what the generated PDF looks like before writing code. Full API documentation is available at /docs. Check the pricing page to choose the right plan for your needs.

Related Guides

Powered by FUNBREW PDF