From f8905bac8c5af83c11f8df5f5d41ff97b1f145a2 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Sat, 6 Jun 2026 19:23:46 +0200 Subject: [PATCH] refactor: make client- and operator-facing error messages clearer and actionable --- CONTRIBUTING.md | 9 +++++++++ pkg/modules/api/context.go | 2 +- pkg/modules/api/middlewares.go | 14 +++++++------- pkg/modules/chromium/chromium.go | 8 ++++---- pkg/modules/libreoffice/api/api.go | 4 ++-- pkg/modules/libreoffice/api/libreoffice.go | 4 ++-- pkg/modules/libreoffice/routes.go | 2 +- pkg/modules/pdfengines/pdfengines.go | 2 +- pkg/modules/qpdf/qpdf.go | 2 +- .../features/chromium_convert_html.feature | 4 ++-- .../features/chromium_convert_markdown.feature | 4 ++-- .../features/chromium_convert_url.feature | 4 ++-- .../features/libreoffice_convert.feature | 4 ++-- .../features/pdfengines_convert.feature | 2 +- test/integration/features/pdfengines_merge.feature | 2 +- .../features/pdfengines_metadata.feature | 2 +- test/integration/features/pdfengines_split.feature | 4 ++-- test/integration/features/pdfengines_stamp.feature | 2 +- .../features/pdfengines_watermark.feature | 2 +- 19 files changed, 43 insertions(+), 34 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e119d4..7aff157 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/pkg/modules/api/context.go b/pkg/modules/api/context.go index 8c9f840..f9e11d4 100644 --- a/pkg/modules/api/context.go +++ b/pkg/modules/api/context.go @@ -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 diff --git a/pkg/modules/api/middlewares.go b/pkg/modules/api/middlewares.go index 6131ba7..7b1d7de 100644 --- a/pkg/modules/api/middlewares.go +++ b/pkg/modules/api/middlewares.go @@ -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 { diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go index 87b0207..b228ed4 100644 --- a/pkg/modules/chromium/chromium.go +++ b/pkg/modules/chromium/chromium.go @@ -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 diff --git a/pkg/modules/libreoffice/api/api.go b/pkg/modules/libreoffice/api/api.go index 99ef93f..a1d5a3d 100644 --- a/pkg/modules/libreoffice/api/api.go +++ b/pkg/modules/libreoffice/api/api.go @@ -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 diff --git a/pkg/modules/libreoffice/api/libreoffice.go b/pkg/modules/libreoffice/api/libreoffice.go index 099fa9c..4d2962a 100644 --- a/pkg/modules/libreoffice/api/libreoffice.go +++ b/pkg/modules/libreoffice/api/libreoffice.go @@ -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) } } } diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go index f706a22..83ce057 100644 --- a/pkg/modules/libreoffice/routes.go +++ b/pkg/modules/libreoffice/routes.go @@ -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), ), ) } diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go index b1c2234..ec96d7f 100644 --- a/pkg/modules/pdfengines/pdfengines.go +++ b/pkg/modules/pdfengines/pdfengines.go @@ -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)) diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go index 88e7168..2e457c2 100644 --- a/pkg/modules/qpdf/qpdf.go +++ b/pkg/modules/qpdf/qpdf.go @@ -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 diff --git a/test/integration/features/chromium_convert_html.feature b/test/integration/features/chromium_convert_html.feature index 03066c0..97ce549 100644 --- a/test/integration/features/chromium_convert_html.feature +++ b/test/integration/features/chromium_convert_html.feature @@ -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 | diff --git a/test/integration/features/chromium_convert_markdown.feature b/test/integration/features/chromium_convert_markdown.feature index 26e2cb4..f7bd12e 100644 --- a/test/integration/features/chromium_convert_markdown.feature +++ b/test/integration/features/chromium_convert_markdown.feature @@ -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 | diff --git a/test/integration/features/chromium_convert_url.feature b/test/integration/features/chromium_convert_url.feature index 3e885fd..b0b21fe 100644 --- a/test/integration/features/chromium_convert_url.feature +++ b/test/integration/features/chromium_convert_url.feature @@ -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): diff --git a/test/integration/features/libreoffice_convert.feature b/test/integration/features/libreoffice_convert.feature index 61e7562..ffb8bf3 100644 --- a/test/integration/features/libreoffice_convert.feature +++ b/test/integration/features/libreoffice_convert.feature @@ -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 | diff --git a/test/integration/features/pdfengines_convert.feature b/test/integration/features/pdfengines_convert.feature index b974318..90ec567 100644 --- a/test/integration/features/pdfengines_convert.feature +++ b/test/integration/features/pdfengines_convert.feature @@ -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 | diff --git a/test/integration/features/pdfengines_merge.feature b/test/integration/features/pdfengines_merge.feature index ac584bf..1c8dd51 100644 --- a/test/integration/features/pdfengines_merge.feature +++ b/test/integration/features/pdfengines_merge.feature @@ -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 | diff --git a/test/integration/features/pdfengines_metadata.feature b/test/integration/features/pdfengines_metadata.feature index 00d780b..0475101 100644 --- a/test/integration/features/pdfengines_metadata.feature +++ b/test/integration/features/pdfengines_metadata.feature @@ -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) diff --git a/test/integration/features/pdfengines_split.feature b/test/integration/features/pdfengines_split.feature index fb34040..3bf1cf6 100644 --- a/test/integration/features/pdfengines_split.feature +++ b/test/integration/features/pdfengines_split.feature @@ -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 | diff --git a/test/integration/features/pdfengines_stamp.feature b/test/integration/features/pdfengines_stamp.feature index a12ab06..7dbc464 100644 --- a/test/integration/features/pdfengines_stamp.feature +++ b/test/integration/features/pdfengines_stamp.feature @@ -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 diff --git a/test/integration/features/pdfengines_watermark.feature b/test/integration/features/pdfengines_watermark.feature index f03534f..1af7471 100644 --- a/test/integration/features/pdfengines_watermark.feature +++ b/test/integration/features/pdfengines_watermark.feature @@ -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