refactor: make client- and operator-facing error messages clearer and actionable

This commit is contained in:
Julien Neuhart
2026-06-06 19:23:46 +02:00
parent 3b1e4cbac4
commit f8905bac8c
19 changed files with 43 additions and 34 deletions
+9
View File
@@ -85,6 +85,15 @@ If a change violates backward compatibility, flag it as a breaking change in the
- No panics in production code paths.
- Validate input defensively.
### Error messages
Client- and operator-facing error messages state what failed, why when non-obvious, and how to fix it when a fix exists. Internal errors (the wrapped `fmt.Errorf` chains that only reach logs) are exempt; keep them precise and technical.
- Client (HTTP response body): name the offending form field and its valid values. Never return a bare `http.StatusText()`.
- Operator (startup, `Provision`, `Validate`): name the environment variable or flag to set, plus the path or value checked.
- Security and filtering errors stay generic for clients. Don't reveal allow/deny lists or private-IP policy. Log the specific reason for operators.
- No hedging ("while others may have failed"). No raw `os.Stat` or exec output in the human-facing remedy.
### Logging
Use `gotenberg.Logger(mod)` to get the module's slog logger during `Provision()`. All log calls must be context-aware: `logger.DebugContext(ctx, msg)`, `logger.InfoContext(ctx, msg)`, `logger.ErrorContext(ctx, msg)`. This propagates trace/span IDs into structured logs when OpenTelemetry is active.
+1 -1
View File
@@ -111,7 +111,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
if bodyLimit != 0 && newTotal > bodyLimit {
return WrapError(
fmt.Errorf("body limit reached (> %d)", bodyLimit),
NewSentinelHttpError(http.StatusRequestEntityTooLarge, http.StatusText(http.StatusRequestEntityTooLarge)),
NewSentinelHttpError(http.StatusRequestEntityTooLarge, "The request body exceeds the configured size limit. Increase it with --api-body-limit, or send a smaller request."),
)
}
return nil
+7 -7
View File
@@ -43,7 +43,7 @@ func ParseError(err error) (int, string) {
}
if errors.Is(err, context.DeadlineExceeded) {
return http.StatusServiceUnavailable, http.StatusText(http.StatusServiceUnavailable)
return http.StatusServiceUnavailable, "The request exceeded the time limit. Increase it with --api-timeout, or reduce the workload."
}
if errors.Is(err, gotenberg.ErrFiltered) {
@@ -51,27 +51,27 @@ func ParseError(err error) (int, string) {
}
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
return http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)
return http.StatusTooManyRequests, "The request queue is full. Retry shortly, or raise the limit with --chromium-max-queue-size or --libreoffice-max-queue-size."
}
if errors.Is(err, gotenberg.ErrPdfSplitModeNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues"
return http.StatusBadRequest, "The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'."
}
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues"
return http.StatusBadRequest, "The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA."
}
if errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues"
return http.StatusBadRequest, "The requested metadata could not be written; ensure values are valid and free of control characters."
}
if errors.Is(err, gotenberg.ErrPdfStampSourceNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested stamp source type, while others may have failed due to different issues"
return http.StatusBadRequest, "The requested stamp source is not supported, or no PDF engine could process it. Valid sources: 'text', 'image', 'pdf'."
}
if errors.Is(err, gotenberg.ErrPdfRotateAngleNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested rotation angle, while others may have failed due to different issues"
return http.StatusBadRequest, "The requested rotation angle is not supported. Valid angles: 90, 180, 270."
}
if invalidArgsError, ok := errors.AsType[*gotenberg.PdfEngineInvalidArgsError](err); ok {
+4 -4
View File
@@ -486,12 +486,12 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
binPath, ok := os.LookupEnv("CHROMIUM_BIN_PATH")
if !ok {
return errors.New("CHROMIUM_BIN_PATH environment variable is not set")
return errors.New("CHROMIUM_BIN_PATH environment variable is not set; set it to the absolute path of the Chromium or Chrome binary")
}
hyphenDataDirPath, ok := os.LookupEnv("CHROMIUM_HYPHEN_DATA_DIR_PATH")
if !ok {
return errors.New("CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable is not set")
return errors.New("CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable is not set; set it to the absolute path of the Chromium hyphenation data directory (it ships in the Gotenberg image)")
}
mod.args = browserArguments{
@@ -664,12 +664,12 @@ func (mod *Chromium) Validate() error {
_, err := os.Stat(mod.args.binPath)
if os.IsNotExist(err) {
return fmt.Errorf("chromium binary path does not exist: %w", err)
return fmt.Errorf("Chromium binary does not exist at %q; check the CHROMIUM_BIN_PATH environment variable: %w", mod.args.binPath, err)
}
_, err = os.Stat(mod.args.hyphenDataDirPath)
if os.IsNotExist(err) {
return fmt.Errorf("chromium hyphen-data directory path does not exist: %w", err)
return fmt.Errorf("Chromium hyphenation data directory does not exist at %q; check the CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable (it ships in the Gotenberg image): %w", mod.args.hyphenDataDirPath, err)
}
return nil
+2 -2
View File
@@ -487,12 +487,12 @@ func (a *Api) Validate() error {
_, statErr := os.Stat(a.args.binPath)
if os.IsNotExist(statErr) {
err = errors.Join(err, fmt.Errorf("LibreOffice binary path does not exist: %w", statErr))
err = errors.Join(err, fmt.Errorf("LibreOffice binary does not exist at %q; check the LIBREOFFICE_BIN_PATH environment variable: %w", a.args.binPath, statErr))
}
_, statErr = os.Stat(a.args.unoBinPath)
if os.IsNotExist(statErr) {
err = errors.Join(err, fmt.Errorf("unoconverter binary path does not exist: %w", statErr))
err = errors.Join(err, fmt.Errorf("unoconverter binary does not exist at %q; check the UNOCONVERTER_BIN_PATH environment variable: %w", a.args.unoBinPath, statErr))
}
return err
+2 -2
View File
@@ -196,7 +196,7 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
select {
case err = <-connChan:
if err != nil {
return fmt.Errorf("LibreOffice socket not available: %w", err)
return fmt.Errorf("LibreOffice did not become available within the start timeout; increase --libreoffice-start-timeout or check system resources: %w", err)
}
logger.DebugContext(context.Background(), "LibreOffice socket available")
@@ -204,7 +204,7 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
return nil
case err = <-waitChan:
return fmt.Errorf("LibreOffice process exited: %w", err)
return fmt.Errorf("LibreOffice exited unexpectedly during startup; check system resources such as memory, disk, and permissions: %w", err)
}
}
}
+1 -1
View File
@@ -400,7 +400,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("A PDF format in '%+v' is not supported", pdfFormats),
fmt.Sprintf("The PDF format '%s' is not supported. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.", pdfFormats.PdfA),
),
)
}
+1 -1
View File
@@ -211,7 +211,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
// actually exist.
func (mod *PdfEngines) Validate() error {
if len(mod.engines) == 0 {
return errors.New("no PDF engine")
return errors.New("no PDF engine is available; enable at least one engine module (e.g. qpdf, pdfcpu, pdftk, libreoffice-pdfengine, exiftool)")
}
availableEngines := make([]string, len(mod.engines))
+1 -1
View File
@@ -46,7 +46,7 @@ func (engine *QPdf) Descriptor() gotenberg.ModuleDescriptor {
func (engine *QPdf) Provision(ctx *gotenberg.Context) error {
binPath, ok := os.LookupEnv("QPDF_BIN_PATH")
if !ok {
return errors.New("QPDF_BIN_PATH environment variable is not set")
return errors.New("QPDF_BIN_PATH environment variable is not set; set it to the absolute path of the qpdf binary")
}
engine.binPath = binPath
@@ -735,7 +735,7 @@ Feature: /forms/chromium/convert/html
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
@@ -744,7 +744,7 @@ Feature: /forms/chromium/convert/html
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
@@ -652,7 +652,7 @@ Feature: /forms/chromium/convert/markdown
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
@@ -662,7 +662,7 @@ Feature: /forms/chromium/convert/markdown
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
@@ -815,7 +815,7 @@ Feature: /forms/chromium/convert/url
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'.
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
@@ -825,7 +825,7 @@ Feature: /forms/chromium/convert/url
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
@@ -313,7 +313,7 @@ Feature: /forms/libreoffice/convert
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'.
"""
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/pages_3.docx | file |
@@ -322,7 +322,7 @@ Feature: /forms/libreoffice/convert
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
A PDF format in '{PdfA:foo PdfUa:false}' is not supported
The PDF format 'foo' is not supported. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
"""
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
@@ -93,7 +93,7 @@ Feature: /forms/pdfengines/convert
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
"""
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
@@ -108,7 +108,7 @@ Feature: /forms/pdfengines/merge
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
"""
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
@@ -120,7 +120,7 @@ Feature: /forms/pdfengines/metadata/{write|read}
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should contain string:
"""
At least one PDF engine cannot process the requested metadata
The requested metadata could not be written
"""
Scenario: POST /forms/pdfengines/metadata/write (Reject Group-Prefixed Dangerous Tag)
@@ -283,7 +283,7 @@ Feature: /forms/pdfengines/split
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'.
"""
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
@@ -294,7 +294,7 @@ Feature: /forms/pdfengines/split
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
"""
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
@@ -83,7 +83,7 @@ Feature: /forms/pdfengines/stamp
Then the response status code should be 400
Then the response body should match string:
"""
At least one PDF engine cannot process the requested stamp source type, while others may have failed due to different issues
The requested stamp source is not supported, or no PDF engine could process it. Valid sources: 'text', 'image', 'pdf'.
"""
@download-from
@@ -83,7 +83,7 @@ Feature: /forms/pdfengines/watermark
Then the response status code should be 400
Then the response body should match string:
"""
At least one PDF engine cannot process the requested stamp source type, while others may have failed due to different issues
The requested stamp source is not supported, or no PDF engine could process it. Valid sources: 'text', 'image', 'pdf'.
"""
@download-from