"), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return fs
+ }(),
+ options: ScreenshotOptions{
+ Options: Options{FailOnResourceLoadingFailed: true},
+ },
+ noDeadline: false,
+ start: true,
+ expectError: true,
+ expectedError: ErrResourceLoadingFailed,
+ },
{
scenario: "clear cache",
browser: newChromiumBrowser(
diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go
index 0a8a8a2..08f3c64 100644
--- a/pkg/modules/chromium/chromium.go
+++ b/pkg/modules/chromium/chromium.go
@@ -38,14 +38,22 @@ var (
// matches with one of the entry in [Options.FailOnHttpStatusCodes].
ErrInvalidHttpStatusCode = errors.New("invalid HTTP status code")
+ // ErrInvalidResourceHttpStatusCode happens when the status code from one
+ // or more resources matches with one of the entry in
+ // [Options.FailOnResourceHttpStatusCodes].
+ ErrInvalidResourceHttpStatusCode = errors.New("invalid resource 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.
ErrConsoleExceptions = errors.New("console exceptions")
- // ErrLoadingFailed happens when a URL failed to load.
+ // ErrLoadingFailed happens when the main page failed to load.
ErrLoadingFailed = errors.New("loading failed")
+ // ErrResourceLoadingFailed happens when one or more resources failed to load.
+ ErrResourceLoadingFailed = errors.New("resource loading failed")
+
// PDF specific.
// ErrOmitBackgroundWithoutPrintBackground happens if
@@ -86,6 +94,14 @@ type Options struct {
// code from the main page matches with one of its entries.
FailOnHttpStatusCodes []int64
+ // FailOnResourceHttpStatusCodes sets if the conversion should fail if the
+ // status code from at least one resource matches with one if its entries.
+ FailOnResourceHttpStatusCodes []int64
+
+ // FailOnResourceLoadingFailed sets if the conversion should fail like the
+ // main page if Chromium fails to load at least one resource.
+ FailOnResourceLoadingFailed bool
+
// FailOnConsoleExceptions sets if the conversion should fail if there are
// exceptions in the Chromium console.
FailOnConsoleExceptions bool
@@ -124,17 +140,19 @@ type Options struct {
// DefaultOptions returns the default values for Options.
func DefaultOptions() Options {
return Options{
- SkipNetworkIdleEvent: true,
- FailOnHttpStatusCodes: []int64{499, 599},
- FailOnConsoleExceptions: false,
- WaitDelay: 0,
- WaitWindowStatus: "",
- WaitForExpression: "",
- Cookies: nil,
- UserAgent: "",
- ExtraHttpHeaders: nil,
- EmulatedMediaType: "",
- OmitBackground: false,
+ SkipNetworkIdleEvent: true,
+ FailOnHttpStatusCodes: []int64{499, 599},
+ FailOnResourceHttpStatusCodes: nil,
+ FailOnResourceLoadingFailed: false,
+ FailOnConsoleExceptions: false,
+ WaitDelay: 0,
+ WaitWindowStatus: "",
+ WaitForExpression: "",
+ Cookies: nil,
+ UserAgent: "",
+ ExtraHttpHeaders: nil,
+ EmulatedMediaType: "",
+ OmitBackground: false,
}
}
diff --git a/pkg/modules/chromium/events.go b/pkg/modules/chromium/events.go
index 85d7145..ec02ef0 100644
--- a/pkg/modules/chromium/events.go
+++ b/pkg/modules/chromium/events.go
@@ -3,6 +3,7 @@ package chromium
import (
"context"
"fmt"
+ "net/http"
"slices"
"sync"
@@ -136,14 +137,36 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
})
}
+type eventResponseReceivedOptions struct {
+ mainPageUrl string
+ failOnHttpStatusCodes []int64
+ invalidHttpStatusCode *error
+ invalidHttpStatusCodeMu *sync.RWMutex
+ failOnResourceOnHttpStatusCode []int64
+ invalidResourceHttpStatusCode *error
+ invalidResourceHttpStatusCodeMu *sync.RWMutex
+}
+
// listenForEventResponseReceived listens for an invalid HTTP status code that
-// 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) {
+// is returned by the main page or by one or more resources.
+// See:
+// https://github.com/gotenberg/gotenberg/issues/613.
+// https://github.com/gotenberg/gotenberg/issues/1021.
+func listenForEventResponseReceived(
+ ctx context.Context,
+ logger *zap.Logger,
+ options eventResponseReceivedOptions,
+) {
for _, code := range []int64{199, 299, 399, 499, 599} {
- if slices.Contains(failOnHttpStatusCodes, code) {
+ if slices.Contains(options.failOnHttpStatusCodes, code) {
for i := code - 99; i <= code; i++ {
- failOnHttpStatusCodes = append(failOnHttpStatusCodes, i)
+ options.failOnHttpStatusCodes = append(options.failOnHttpStatusCodes, i)
+ }
+ }
+
+ if slices.Contains(options.failOnResourceOnHttpStatusCode, code) {
+ for i := code - 99; i <= code; i++ {
+ options.failOnResourceOnHttpStatusCode = append(options.failOnResourceOnHttpStatusCode, i)
}
}
}
@@ -151,41 +174,53 @@ func listenForEventResponseReceived(ctx context.Context, logger *zap.Logger, url
chromedp.ListenTarget(ctx, func(ev interface{}) {
switch ev := ev.(type) {
case *network.EventResponseReceived:
- if ev.Response.URL != url {
+ if ev.Response.URL == options.mainPageUrl {
+ logger.Debug(fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
+
+ if slices.Contains(options.failOnHttpStatusCodes, ev.Response.Status) {
+ options.invalidHttpStatusCodeMu.Lock()
+ defer options.invalidHttpStatusCodeMu.Unlock()
+
+ *options.invalidHttpStatusCode = fmt.Errorf("%d: %s", ev.Response.Status, ev.Response.StatusText)
+ }
+
return
}
- logger.Debug(fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
+ logger.Debug(fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response))
- if slices.Contains(failOnHttpStatusCodes, ev.Response.Status) {
- invalidHttpStatusCodeMu.Lock()
- defer invalidHttpStatusCodeMu.Unlock()
+ if slices.Contains(options.failOnResourceOnHttpStatusCode, ev.Response.Status) {
+ options.invalidResourceHttpStatusCodeMu.Lock()
+ defer options.invalidResourceHttpStatusCodeMu.Unlock()
- *invalidHttpStatusCode = fmt.Errorf("%d: %s", ev.Response.Status, ev.Response.StatusText)
+ *options.invalidResourceHttpStatusCode = multierr.Append(
+ *options.invalidResourceHttpStatusCode,
+ fmt.Errorf("%s - %d: %s", ev.Response.URL, ev.Response.Status, http.StatusText(int(ev.Response.Status))),
+ )
}
}
})
}
+type eventLoadingFailedOptions struct {
+ loadingFailed *error
+ loadingFailedMu *sync.RWMutex
+ resourceLoadingFailed *error
+ resourceLoadingFailedMu *sync.RWMutex
+}
+
// listenForEventLoadingFailed listens for an event indicating that the main
-// page failed to load.
+// page or one or more resources failed to load.
// See:
// https://github.com/gotenberg/gotenberg/issues/913.
// https://github.com/gotenberg/gotenberg/issues/959.
-func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, loadingFailed *error, loadingFailedMu *sync.RWMutex) {
+// https://github.com/gotenberg/gotenberg/issues/1021.
+func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, options eventLoadingFailedOptions) {
chromedp.ListenTarget(ctx, func(ev interface{}) {
switch ev := ev.(type) {
case *network.EventLoadingFailed:
logger.Debug(fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
- if ev.Type != network.ResourceTypeDocument {
- logger.Debug("skip EventLoadingFailed: is not resource type Document")
- return
- }
-
- // Supposition: except iframe, an event loading failed with a
- // resource type Document is about the main page.
-
// We are looking for common errors.
// TODO: sufficient?
errors := []string{
@@ -199,16 +234,35 @@ func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, loadin
"net::ERR_ADDRESS_UNREACHABLE",
"net::ERR_BLOCKED_BY_CLIENT",
"net::ERR_BLOCKED_BY_RESPONSE",
+ "net::ERR_FILE_NOT_FOUND",
}
if !slices.Contains(errors, ev.ErrorText) {
logger.Debug(fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors))
return
}
- loadingFailedMu.Lock()
- defer loadingFailedMu.Unlock()
+ if ev.Type == network.ResourceTypeDocument {
+ // Supposition: except iframe, an event loading failed with a
+ // resource type Document is about the main page.
+ logger.Debug("event EventLoadingFailed fired for main page")
- *loadingFailed = fmt.Errorf("%s", ev.ErrorText)
+ options.loadingFailedMu.Lock()
+ defer options.loadingFailedMu.Unlock()
+
+ *options.loadingFailed = fmt.Errorf("%s", ev.ErrorText)
+
+ return
+ }
+
+ logger.Debug("event EventLoadingFailed fired for a resource")
+
+ options.resourceLoadingFailedMu.Lock()
+ defer options.resourceLoadingFailedMu.Unlock()
+
+ *options.resourceLoadingFailed = multierr.Append(
+ *options.resourceLoadingFailed,
+ fmt.Errorf("resource %s: %s", ev.Type, ev.ErrorText),
+ )
}
})
}
diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go
index a725c7c..4662371 100644
--- a/pkg/modules/chromium/routes.go
+++ b/pkg/modules/chromium/routes.go
@@ -29,17 +29,19 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
defaultOptions := DefaultOptions()
var (
- skipNetworkIdleEvent bool
- failOnHttpStatusCodes []int64
- failOnConsoleExceptions bool
- waitDelay time.Duration
- waitWindowStatus string
- waitForExpression string
- cookies []Cookie
- userAgent string
- extraHttpHeaders []ExtraHttpHeader
- emulatedMediaType string
- omitBackground bool
+ skipNetworkIdleEvent bool
+ failOnHttpStatusCodes []int64
+ failOnResourceHttpStatusCodes []int64
+ failOnResourceLoadingFailed bool
+ failOnConsoleExceptions bool
+ waitDelay time.Duration
+ waitWindowStatus string
+ waitForExpression string
+ cookies []Cookie
+ userAgent string
+ extraHttpHeaders []ExtraHttpHeader
+ emulatedMediaType string
+ omitBackground bool
)
form := ctx.FormData().
@@ -57,6 +59,20 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
return nil
}).
+ Custom("failOnResourceHttpStatusCodes", func(value string) error {
+ if value == "" {
+ failOnResourceHttpStatusCodes = defaultOptions.FailOnResourceHttpStatusCodes
+ return nil
+ }
+
+ err := json.Unmarshal([]byte(value), &failOnResourceHttpStatusCodes)
+ if err != nil {
+ return fmt.Errorf("unmarshal failOnResourceHttpStatusCodes: %w", err)
+ }
+
+ return nil
+ }).
+ Bool("failOnResourceLoadingFailed", &failOnResourceLoadingFailed, defaultOptions.FailOnResourceLoadingFailed).
Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions).
Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus).
@@ -158,17 +174,19 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground)
options := Options{
- SkipNetworkIdleEvent: skipNetworkIdleEvent,
- FailOnHttpStatusCodes: failOnHttpStatusCodes,
- FailOnConsoleExceptions: failOnConsoleExceptions,
- WaitDelay: waitDelay,
- WaitWindowStatus: waitWindowStatus,
- WaitForExpression: waitForExpression,
- Cookies: cookies,
- UserAgent: userAgent,
- ExtraHttpHeaders: extraHttpHeaders,
- EmulatedMediaType: emulatedMediaType,
- OmitBackground: omitBackground,
+ SkipNetworkIdleEvent: skipNetworkIdleEvent,
+ FailOnHttpStatusCodes: failOnHttpStatusCodes,
+ FailOnResourceHttpStatusCodes: failOnResourceHttpStatusCodes,
+ FailOnResourceLoadingFailed: failOnResourceLoadingFailed,
+ FailOnConsoleExceptions: failOnConsoleExceptions,
+ WaitDelay: waitDelay,
+ WaitWindowStatus: waitWindowStatus,
+ WaitForExpression: waitForExpression,
+ Cookies: cookies,
+ UserAgent: userAgent,
+ ExtraHttpHeaders: extraHttpHeaders,
+ EmulatedMediaType: emulatedMediaType,
+ OmitBackground: omitBackground,
}
return form, options
@@ -726,12 +744,22 @@ func handleChromiumError(err error, options Options) error {
)
}
+ if errors.Is(err, ErrInvalidResourceHttpStatusCode) {
+ return api.WrapError(
+ err,
+ api.NewSentinelHttpError(
+ http.StatusConflict,
+ fmt.Sprintf("Invalid HTTP status code from resources:\n%s", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrInvalidResourceHttpStatusCode.Error()), "")),
+ ),
+ )
+ }
+
if errors.Is(err, ErrConsoleExceptions) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusConflict,
- fmt.Sprintf("Chromium console exceptions:\n %s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
+ fmt.Sprintf("Chromium console exceptions:\n%s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
),
)
}
@@ -746,5 +774,15 @@ func handleChromiumError(err error, options Options) error {
)
}
+ if errors.Is(err, ErrResourceLoadingFailed) {
+ return api.WrapError(
+ err,
+ api.NewSentinelHttpError(
+ http.StatusConflict,
+ fmt.Sprintf("Chromium failed to load resources: %v", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrResourceLoadingFailed.Error()), "")),
+ ),
+ )
+ }
+
return err
}
diff --git a/pkg/modules/chromium/routes_test.go b/pkg/modules/chromium/routes_test.go
index 29fb9d1..629dfbb 100644
--- a/pkg/modules/chromium/routes_test.go
+++ b/pkg/modules/chromium/routes_test.go
@@ -72,6 +72,44 @@ func TestFormDataChromiumOptions(t *testing.T) {
compareWithoutDeepEqual: false,
expectValidationError: false,
},
+ {
+ scenario: "invalid failOnResourceHttpStatusCodes form field",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetValues(map[string][]string{
+ "failOnResourceHttpStatusCodes": {
+ "foo",
+ },
+ })
+ return ctx
+ }(),
+ expectedOptions: func() Options {
+ options := DefaultOptions()
+ options.FailOnResourceHttpStatusCodes = nil
+ return options
+ }(),
+ compareWithoutDeepEqual: false,
+ expectValidationError: true,
+ },
+ {
+ scenario: "valid failOnResourceHttpStatusCodes form field",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetValues(map[string][]string{
+ "failOnResourceHttpStatusCodes": {
+ `[399,499,599]`,
+ },
+ })
+ return ctx
+ }(),
+ expectedOptions: func() Options {
+ options := DefaultOptions()
+ options.FailOnResourceHttpStatusCodes = []int64{399, 499, 599}
+ return options
+ }(),
+ compareWithoutDeepEqual: false,
+ expectValidationError: false,
+ },
{
scenario: "invalid cookies form field",
ctx: func() *api.ContextMock {
@@ -1592,6 +1630,18 @@ func TestConvertUrl(t *testing.T) {
expectHttpStatus: http.StatusConflict,
expectOutputPathsCount: 0,
},
+ {
+ scenario: "ErrInvalidResourceHttpStatusCode",
+ ctx: &api.ContextMock{Context: new(api.Context)},
+ api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
+ return ErrInvalidResourceHttpStatusCode
+ }},
+ options: DefaultPdfOptions(),
+ expectError: true,
+ expectHttpError: true,
+ expectHttpStatus: http.StatusConflict,
+ expectOutputPathsCount: 0,
+ },
{
scenario: "ErrConsoleExceptions",
ctx: &api.ContextMock{Context: new(api.Context)},
@@ -1616,6 +1666,18 @@ func TestConvertUrl(t *testing.T) {
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
+ {
+ scenario: "ErrResourceLoadingFailed",
+ ctx: &api.ContextMock{Context: new(api.Context)},
+ api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
+ return ErrResourceLoadingFailed
+ }},
+ options: DefaultPdfOptions(),
+ expectError: true,
+ expectHttpError: true,
+ expectHttpStatus: http.StatusConflict,
+ expectOutputPathsCount: 0,
+ },
{
scenario: "error from Chromium",
ctx: &api.ContextMock{Context: new(api.Context)},
@@ -1802,6 +1864,18 @@ func TestScreenshotUrl(t *testing.T) {
expectHttpStatus: http.StatusConflict,
expectOutputPathsCount: 0,
},
+ {
+ scenario: "ErrInvalidResourceHttpStatusCode",
+ ctx: &api.ContextMock{Context: new(api.Context)},
+ api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
+ return ErrInvalidResourceHttpStatusCode
+ }},
+ options: DefaultScreenshotOptions(),
+ expectError: true,
+ expectHttpError: true,
+ expectHttpStatus: http.StatusConflict,
+ expectOutputPathsCount: 0,
+ },
{
scenario: "ErrConsoleExceptions",
ctx: &api.ContextMock{Context: new(api.Context)},
@@ -1826,6 +1900,18 @@ func TestScreenshotUrl(t *testing.T) {
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
+ {
+ scenario: "ErrResourceLoadingFailed",
+ ctx: &api.ContextMock{Context: new(api.Context)},
+ api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
+ return ErrResourceLoadingFailed
+ }},
+ options: DefaultScreenshotOptions(),
+ expectError: true,
+ expectHttpError: true,
+ expectHttpStatus: http.StatusConflict,
+ expectOutputPathsCount: 0,
+ },
{
scenario: "error from Chromium",
ctx: &api.ContextMock{Context: new(api.Context)},
diff --git a/test/testdata/chromium/html/index.html b/test/testdata/chromium/html/index.html
index 29a787b..a19f166 100644
--- a/test/testdata/chromium/html/index.html
+++ b/test/testdata/chromium/html/index.html
@@ -2,6 +2,12 @@
+
+
+
+
+
+
Gutenberg
@@ -23,7 +29,7 @@
It is a press, certainly, but a press from which shall flow in inexhaustible streams...Through it, God will spread His Word. A spring of truth shall flow from it: like a new star it shall scatter the darkness of ignorance, and cause a light heretofore unknown to shine amongst men.
It is a press, certainly, but a press from which shall flow in inexhaustible streams...Through it, God will spread His Word. A spring of truth shall flow from it: like a new star it shall scatter the darkness of ignorance, and cause a light heretofore unknown to shine amongst men.