Repository: gotenberg/gotenberg (go)

Issue:
Propagating error in chromium module
Hi, 

In the chromium module, we are using a microservice that generate html files. Sometime it throws exceptions and right now, gotenberg return a pdf with the exception message as content. in those cases, we would want gotenberg to respond with an error so we can handle it on our side. 

Is it something you could add in the futur?
Thanks!

Pull request description:
feat(chromium): return 409 Conflict if HTTP status code from main page is not acceptable
Closes #613.

### New Feature

Now returns a `409 Conflict` if the HTTP status code from the main page is not acceptable.

The list of invalid status codes is configurable with the new form field `failOnHttpStatusCodes` that takes a JSON string representing an array of int. For instance:

```
curl --request POST \
  --url http://localhost:3000/forms/chromium/convert/url \
  --form url="https://httpstat.us/400" \
  --form 'failOnHttpStatusCodes="[499,599]"' 
```

* A `x99` represents the range between `x00` and `x99` (e.g., `499` means every HTTP status codes between `400` and `499`).
* Default value of `failOnHttpStatusCodes` is `"[499,599]"`.

Patch:
diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go
index adfb774..96f1829 100644
--- a/pkg/modules/chromium/browser.go
+++ b/pkg/modules/chromium/browser.go
@@ -243,6 +243,16 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
 	// If a request does not pass the validation, we make it fail.
 	listenForEventRequestPaused(taskCtx, logger, b.arguments.allowList, b.arguments.denyList)
 
+	var (
+		invalidHttpStatusCode   error
+		invalidHttpStatusCodeMu sync.RWMutex
+	)
+
+	// See https://github.com/gotenberg/gotenberg/issues/613.
+	if len(options.FailOnHttpStatusCodes) != 0 {
+		listenForEventResponseReceived(taskCtx, logger, url, options.FailOnHttpStatusCodes, &invalidHttpStatusCode, &invalidHttpStatusCodeMu)
+	}
+
 	var (
 		consoleExceptions   error
 		consoleExceptionsMu sync.RWMutex
@@ -287,6 +297,14 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
 		return fmt.Errorf("print to PDF: %w", err)
 	}
 
+	// See https://github.com/gotenberg/gotenberg/issues/613.
+	invalidHttpStatusCodeMu.RLock()
+	defer invalidHttpStatusCodeMu.RUnlock()
+
+	if invalidHttpStatusCode != nil {
+		return fmt.Errorf("%v: %w", invalidHttpStatusCode, ErrInvalidHttpStatusCode)
+	}
+
 	// See https://github.com/gotenberg/gotenberg/issues/262.
 	consoleExceptionsMu.RLock()
 	defer consoleExceptionsMu.RUnlock()
diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go
index ba9c3f1..640c1e1 100644
--- a/pkg/modules/chromium/chromium.go
+++ b/pkg/modules/chromium/chromium.go
@@ -48,6 +48,10 @@ var (
 	// ChromeDevTools are larger than 100 MB.
 	ErrRpccMessageTooLarge = errors.New("rpcc message too large")
 
+	// ErrInvalidHttpStatusCode happens when the status code from the main page
+	// matches with one of the entry in [Options.FailOnHttpStatusCodes].
+	ErrInvalidHttpStatusCode = errors.New("invalid HTTP status code")
+
 	// ErrConsoleExceptions happens when there are exceptions in the Chromium
 	// console. It also happens only if the [Options.FailOnConsoleExceptions]
 	// is set to true.
@@ -76,6 +80,11 @@ type Options struct {
 	// Optional.
 	SkipNetworkIdleEvent bool
 
+	// FailOnHttpStatusCodes sets if the conversion should fail if the status
+	// code from the main page matches with one of its entries.
+	// Optional.
+	FailOnHttpStatusCodes []int64
+
 	// FailOnConsoleExceptions sets if the conversion should fail if there are
 	// exceptions in the Chromium console.
 	// Optional.
@@ -179,6 +188,7 @@ type Options struct {
 func DefaultOptions() Options {
 	return Options{
 		SkipNetworkIdleEvent:    false,
+		FailOnHttpStatusCodes:   []int64{499, 599},
 		FailOnConsoleExceptions: false,
 		WaitDelay:               0,
 		WaitWindowStatus:        "",
@@ -436,7 +446,7 @@ func (mod *Chromium) Routes() ([]api.Route, error) {
 
 // Pdf converts a URL to PDF.
 func (mod *Chromium) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
-	// FIXME: no error wrapping because it leaks on console exceptions output.
+	// Note: no error wrapping because it leaks on console exceptions output.
 	return mod.supervisor.Run(ctx, logger, func() error {
 		return mod.browser.pdf(ctx, logger, url, outputPath, options)
 	})
diff --git a/pkg/modules/chromium/events.go b/pkg/modules/chromium/events.go
index 257821b..e5128c3 100644
--- a/pkg/modules/chromium/events.go
+++ b/pkg/modules/chromium/events.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 	"regexp"
+	"slices"
 	"sync"
 
 	"github.com/chromedp/cdproto/cdp"
@@ -60,6 +61,37 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL
 	})
 }
 
+// listenForEventResponseReceived listens for an invalid HTTP status code is
+// returned by the main page.
+// See https://github.com/gotenberg/gotenberg/issues/613.
+func listenForEventResponseReceived(ctx context.Context, logger *zap.Logger, url string, failOnHttpStatusCodes []int64, invalidHttpStatusCode *error, invalidHttpStatusCodeMu *sync.RWMutex) {
+	for _, code := range []int64{199, 299, 399, 499, 599} {
+		if slices.Contains(failOnHttpStatusCodes, code) {
+			for i := code - 99; i <= code; i++ {
+				failOnHttpStatusCodes = append(failOnHttpStatusCodes, i)
+			}
+		}
+	}
+
+	chromedp.ListenTarget(ctx, func(ev interface{}) {
+		switch ev := ev.(type) {
+		case *network.EventResponseReceived:
+			if ev.Response.URL != url {
+				return
+			}
+
+			logger.Debug(fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
+
+			if slices.Contains(failOnHttpStatusCodes, ev.Response.Status) {
+				invalidHttpStatusCodeMu.Lock()
+				defer invalidHttpStatusCodeMu.Unlock()
+
+				*invalidHttpStatusCode = fmt.Errorf("%d: %s", ev.Response.Status, ev.Response.StatusText)
+			}
+		}
+	})
+}
+
 // listenForEventExceptionThrown listens for exceptions in the console and
 // appends those exceptions to the given error pointer.
 // See https://github.com/gotenberg/gotenberg/issues/262.
diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go
index 1d5038a..3d4d630 100644
--- a/pkg/modules/chromium/routes.go
+++ b/pkg/modules/chromium/routes.go
@@ -28,6 +28,7 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, Options) {
 
 	var (
 		skipNetworkIdleEvent                             bool
+		failOnHttpStatusCodes                            []int64
 		failOnConsoleExceptions                          bool
 		waitDelay                                        time.Duration
 		waitWindowStatus                                 string
@@ -44,6 +45,19 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, Options) {
 
 	form := ctx.FormData().
 		Bool("skipNetworkIdleEvent", &skipNetworkIdleEvent, defaultOptions.SkipNetworkIdleEvent).
+		Custom("failOnHttpStatusCodes", func(value string) error {
+			if value == "" {
+				failOnHttpStatusCodes = defaultOptions.FailOnHttpStatusCodes
+				return nil
+			}
+
+			err := json.Unmarshal([]byte(value), &failOnHttpStatusCodes)
+			if err != nil {
+				return fmt.Errorf("unmarshal failOnHttpStatusCodes: %w", err)
+			}
+
+			return nil
+		}).
 		Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions).
 		Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
 		String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus).
@@ -51,13 +65,12 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, Options) {
 		Custom("extraHttpHeaders", func(value string) error {
 			if value == "" {
 				extraHttpHeaders = defaultOptions.ExtraHttpHeaders
-
 				return nil
 			}
 
 			err := json.Unmarshal([]byte(value), &extraHttpHeaders)
 			if err != nil {
-				return fmt.Errorf("unmarshal extra HTTP headers: %w", err)
+				return fmt.Errorf("unmarshal extraHttpHeaders: %w", err)
 			}
 
 			return nil
@@ -65,7 +78,6 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, Options) {
 		Custom("emulatedMediaType", func(value string) error {
 			if value == "" {
 				emulatedMediaType = defaultOptions.EmulatedMediaType
-
 				return nil
 			}
 
@@ -94,6 +106,7 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, Options) {
 
 	options := Options{
 		SkipNetworkIdleEvent:    skipNetworkIdleEvent,
+		FailOnHttpStatusCodes:   failOnHttpStatusCodes,
 		FailOnConsoleExceptions: failOnConsoleExceptions,
 		WaitDelay:               waitDelay,
 		WaitWindowStatus:        waitWindowStatus,
@@ -369,6 +382,16 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
 			)
 		}
 
+		if errors.Is(err, ErrInvalidHttpStatusCode) {
+			return api.WrapError(
+				fmt.Errorf("convert to PDF: %w", err),
+				api.NewSentinelHttpError(
+					http.StatusConflict,
+					fmt.Sprintf("Invalid HTTP status code from the main page: %s", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrInvalidHttpStatusCode.Error()), "")),
+				),
+			)
+		}
+
 		if errors.Is(err, ErrConsoleExceptions) {
 			return api.WrapError(
 				fmt.Errorf("convert to PDF: %w", err),


Test patch:
diff --git a/pkg/modules/chromium/browser_test.go b/pkg/modules/chromium/browser_test.go
index afe9e21..59b9939 100644
--- a/pkg/modules/chromium/browser_test.go
+++ b/pkg/modules/chromium/browser_test.go
@@ -415,6 +415,39 @@ func TestChromiumBrowser_pdf(t *testing.T) {
 				"skipping network idle event",
 			},
 		},
+		{
+			scenario: "ErrInvalidHttpStatusCode",
+			browser: newChromiumBrowser(
+				browserArguments{
+					binPath:          os.Getenv("CHROMIUM_BIN_PATH"),
+					wsUrlReadTimeout: 5 * time.Second,
+					allowList:        regexp.MustCompile(""),
+					denyList:         regexp.MustCompile(""),
+				},
+			),
+			fs: func() *gotenberg.FileSystem {
+				fs := gotenberg.NewFileSystem()
+
+				err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
+				if err != nil {
+					t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
+				}
+
+				err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrInvalidHttpStatusCode</h1>"), 0o755)
+				if err != nil {
+					t.Fatalf("expected no error but got: %v", err)
+				}
+
+				return fs
+			}(),
+			options: Options{
+				FailOnHttpStatusCodes: []int64{299},
+			},
+			noDeadline:    false,
+			start:         true,
+			expectError:   true,
+			expectedError: ErrInvalidHttpStatusCode,
+		},
 		{
 			scenario: "ErrConsoleExceptions",
 			browser: newChromiumBrowser(
diff --git a/pkg/modules/chromium/routes_test.go b/pkg/modules/chromium/routes_test.go
index a5d5a74..2222c17 100644
--- a/pkg/modules/chromium/routes_test.go
+++ b/pkg/modules/chromium/routes_test.go
@@ -28,6 +28,40 @@ func TestFormDataChromiumPdfOptions(t *testing.T) {
 			ctx:             &api.ContextMock{Context: new(api.Context)},
 			expectedOptions: DefaultOptions(),
 		},
+		{
+			scenario: "invalid failOnHttpStatusCodes form field",
+			ctx: func() *api.ContextMock {
+				ctx := &api.ContextMock{Context: new(api.Context)}
+				ctx.SetValues(map[string][]string{
+					"failOnHttpStatusCodes": {
+						"foo",
+					},
+				})
+				return ctx
+			}(),
+			expectedOptions: func() Options {
+				options := DefaultOptions()
+				options.FailOnHttpStatusCodes = nil
+				return options
+			}(),
+		},
+		{
+			scenario: "valid failOnHttpStatusCodes form field",
+			ctx: func() *api.ContextMock {
+				ctx := &api.ContextMock{Context: new(api.Context)}
+				ctx.SetValues(map[string][]string{
+					"failOnHttpStatusCodes": {
+						`[399,499,599]`,
+					},
+				})
+				return ctx
+			}(),
+			expectedOptions: func() Options {
+				options := DefaultOptions()
+				options.FailOnHttpStatusCodes = []int64{399, 499, 599}
+				return options
+			}(),
+		},
 		{
 			scenario: "invalid extraHttpHeaders form field",
 			ctx: func() *api.ContextMock {
@@ -639,6 +673,18 @@ func TestConvertUrl(t *testing.T) {
 			expectHttpStatus:       http.StatusBadRequest,
 			expectOutputPathsCount: 0,
 		},
+		{
+			scenario: "ErrInvalidHttpStatusCode",
+			ctx:      &api.ContextMock{Context: new(api.Context)},
+			api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+				return ErrInvalidHttpStatusCode
+			}},
+			options:                DefaultOptions(),
+			expectError:            true,
+			expectHttpError:        true,
+			expectHttpStatus:       http.StatusConflict,
+			expectOutputPathsCount: 0,
+		},
 		{
 			scenario: "ErrConsoleExceptions",
 			ctx:      &api.ContextMock{Context: new(api.Context)},