Browse Source

Revised errors for better error reporting

DarthSim 2 months ago
parent
commit
528ece8da1

+ 67 - 0
errors.go

@@ -0,0 +1,67 @@
+package main
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/imgproxy/imgproxy/v3/ierrors"
+)
+
+type (
+	ResponseWriteError   struct{ error }
+	InvalidURLError      string
+	TooManyRequestsError struct{}
+	InvalidSecretError   struct{}
+)
+
+func newResponseWriteError(cause error) *ierrors.Error {
+	return ierrors.Wrap(
+		ResponseWriteError{cause},
+		1,
+		ierrors.WithPublicMessage("Failed to write response"),
+	)
+}
+
+func (e ResponseWriteError) Error() string {
+	return fmt.Sprintf("Failed to write response: %s", e.error)
+}
+
+func (e ResponseWriteError) Unwrap() error {
+	return e.error
+}
+
+func newInvalidURLErrorf(status int, format string, args ...interface{}) error {
+	return ierrors.Wrap(
+		InvalidURLError(fmt.Sprintf(format, args...)),
+		1,
+		ierrors.WithStatusCode(status),
+		ierrors.WithPublicMessage("Invalid URL"),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e InvalidURLError) Error() string { return string(e) }
+
+func newTooManyRequestsError() error {
+	return ierrors.Wrap(
+		TooManyRequestsError{},
+		1,
+		ierrors.WithStatusCode(http.StatusTooManyRequests),
+		ierrors.WithPublicMessage("Too many requests"),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e TooManyRequestsError) Error() string { return "Too many requests" }
+
+func newInvalidSecretError() error {
+	return ierrors.Wrap(
+		InvalidSecretError{},
+		1,
+		ierrors.WithStatusCode(http.StatusForbidden),
+		ierrors.WithPublicMessage("Forbidden"),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e InvalidSecretError) Error() string { return "Invalid secret" }

+ 106 - 42
ierrors/errors.go

@@ -2,83 +2,147 @@ package ierrors
 
 import (
 	"fmt"
+	"net/http"
 	"runtime"
 	"strings"
 )
 
+type Option func(*Error)
+
 type Error struct {
-	StatusCode    int
-	Message       string
-	PublicMessage string
-	Unexpected    bool
+	err error
+
+	prefix        string
+	statusCode    int
+	publicMessage string
+	shouldReport  bool
 
 	stack []uintptr
 }
 
 func (e *Error) Error() string {
-	return e.Message
+	if len(e.prefix) > 0 {
+		return fmt.Sprintf("%s: %s", e.prefix, e.err.Error())
+	}
+
+	return e.err.Error()
 }
 
-func (e *Error) FormatStack() string {
-	if e.stack == nil {
-		return ""
+func (e *Error) Unwrap() error {
+	return e.err
+}
+
+func (e *Error) Cause() error {
+	return e.err
+}
+
+func (e *Error) StatusCode() int {
+	if e.statusCode <= 0 {
+		return http.StatusInternalServerError
+	}
+
+	return e.statusCode
+}
+
+func (e *Error) PublicMessage() string {
+	if len(e.publicMessage) == 0 {
+		return "Internal error"
 	}
 
-	return formatStack(e.stack)
+	return e.publicMessage
+}
+
+func (e *Error) ShouldReport() bool {
+	return e.shouldReport
 }
 
 func (e *Error) StackTrace() []uintptr {
 	return e.stack
 }
 
-func New(status int, msg string, pub string) *Error {
-	return &Error{
-		StatusCode:    status,
-		Message:       msg,
-		PublicMessage: pub,
-	}
+func (e *Error) Callers() []uintptr {
+	return e.stack
 }
 
-func NewUnexpected(msg string, skip int) *Error {
-	return &Error{
-		StatusCode:    500,
-		Message:       msg,
-		PublicMessage: "Internal error",
-		Unexpected:    true,
+func (e *Error) FormatStackLines() []string {
+	lines := make([]string, len(e.stack))
 
-		stack: callers(skip + 3),
+	for i, pc := range e.stack {
+		f := runtime.FuncForPC(pc)
+		file, line := f.FileLine(pc)
+		lines[i] = fmt.Sprintf("%s:%d %s", file, line, f.Name())
 	}
+
+	return lines
+}
+
+func (e *Error) FormatStack() string {
+	return strings.Join(e.FormatStackLines(), "\n")
 }
 
-func Wrap(err error, skip int) *Error {
+func Wrap(err error, stackSkip int, opts ...Option) *Error {
+	if err == nil {
+		return nil
+	}
+
+	var e *Error
+
 	if ierr, ok := err.(*Error); ok {
-		return ierr
+		// if we have some options, we need to copy the error to not modify the original one
+		if len(opts) > 0 {
+			ecopy := *ierr
+			e = &ecopy
+		} else {
+			return ierr
+		}
+	} else {
+		e = &Error{
+			err:          err,
+			shouldReport: true,
+		}
+	}
+
+	for _, opt := range opts {
+		opt(e)
+	}
+
+	if len(e.stack) == 0 {
+		e.stack = callers(stackSkip + 1)
 	}
-	return NewUnexpected(err.Error(), skip+1)
+
+	return e
 }
 
-func WrapWithPrefix(err error, skip int, prefix string) *Error {
-	if ierr, ok := err.(*Error); ok {
-		newErr := *ierr
-		newErr.Message = fmt.Sprintf("%s: %s", prefix, ierr.Message)
-		return &newErr
+func WithStatusCode(code int) Option {
+	return func(e *Error) {
+		e.statusCode = code
 	}
-	return NewUnexpected(fmt.Sprintf("%s: %s", prefix, err), skip+1)
 }
 
-func callers(skip int) []uintptr {
-	stack := make([]uintptr, 10)
-	n := runtime.Callers(skip, stack)
-	return stack[:n]
+func WithPublicMessage(msg string) Option {
+	return func(e *Error) {
+		e.publicMessage = msg
+	}
 }
 
-func formatStack(stack []uintptr) string {
-	lines := make([]string, len(stack))
-	for i, pc := range stack {
-		f := runtime.FuncForPC(pc)
-		file, line := f.FileLine(pc)
-		lines[i] = fmt.Sprintf("%s:%d %s", file, line, f.Name())
+func WithPrefix(prefix string) Option {
+	return func(e *Error) {
+		if len(e.prefix) > 0 {
+			e.prefix = fmt.Sprintf("%s: %s", prefix, e.prefix)
+		} else {
+			e.prefix = prefix
+		}
 	}
+}
 
-	return strings.Join(lines, "\n")
+func WithShouldReport(report bool) Option {
+	return func(e *Error) {
+		e.shouldReport = report
+	}
+}
+
+func callers(skip int) []uintptr {
+	stack := make([]uintptr, 10)
+	n := runtime.Callers(skip+2, stack)
+	return stack[:n]
 }

+ 10 - 30
imagedata/download.go

@@ -53,15 +53,6 @@ type DownloadOptions struct {
 	CookieJar http.CookieJar
 }
 
-type ErrorNotModified struct {
-	Message string
-	Headers map[string]string
-}
-
-func (e *ErrorNotModified) Error() string {
-	return e.Message
-}
-
 func initDownloading() error {
 	transport, err := defaultTransport.New(true)
 	if err != nil {
@@ -143,16 +134,12 @@ func BuildImageRequest(ctx context.Context, imageURL string, header http.Header,
 	req, err := http.NewRequestWithContext(reqCtx, "GET", imageURL, nil)
 	if err != nil {
 		reqCancel()
-		return nil, func() {}, ierrors.New(404, err.Error(), msgSourceImageIsUnreachable)
+		return nil, func() {}, newImageRequestError(err)
 	}
 
 	if _, ok := enabledSchemes[req.URL.Scheme]; !ok {
 		reqCancel()
-		return nil, func() {}, ierrors.New(
-			404,
-			fmt.Sprintf("Unknown scheme: %s", req.URL.Scheme),
-			msgSourceImageIsUnreachable,
-		)
+		return nil, func() {}, newImageRequstSchemeError(req.URL.Scheme)
 	}
 
 	if jar != nil {
@@ -226,7 +213,7 @@ func requestImage(ctx context.Context, imageURL string, opts DownloadOptions) (*
 	if res.StatusCode == http.StatusNotModified {
 		res.Body.Close()
 		reqCancel()
-		return nil, func() {}, &ErrorNotModified{Message: "Not Modified", Headers: headersToStore(res)}
+		return nil, func() {}, newNotModifiedError(headersToStore(res))
 	}
 
 	// If the source responds with 206, check if the response contains entire image.
@@ -237,13 +224,13 @@ func requestImage(ctx context.Context, imageURL string, opts DownloadOptions) (*
 		if len(rangeParts) == 0 {
 			res.Body.Close()
 			reqCancel()
-			return nil, func() {}, ierrors.New(404, "Partial response with invalid Content-Range header", msgSourceImageIsUnreachable)
+			return nil, func() {}, newImagePartialResponseError("Partial response with invalid Content-Range header")
 		}
 
 		if rangeParts[1] == "*" || rangeParts[2] != "0" {
 			res.Body.Close()
 			reqCancel()
-			return nil, func() {}, ierrors.New(404, "Partial response with incomplete content", msgSourceImageIsUnreachable)
+			return nil, func() {}, newImagePartialResponseError("Partial response with incomplete content")
 		}
 
 		contentLengthStr := rangeParts[4]
@@ -257,27 +244,20 @@ func requestImage(ctx context.Context, imageURL string, opts DownloadOptions) (*
 		if contentLength <= 0 || rangeEnd != contentLength-1 {
 			res.Body.Close()
 			reqCancel()
-			return nil, func() {}, ierrors.New(404, "Partial response with incomplete content", msgSourceImageIsUnreachable)
+			return nil, func() {}, newImagePartialResponseError("Partial response with incomplete content")
 		}
 	} else if res.StatusCode != http.StatusOK {
-		var msg string
+		var body string
 
 		if strings.HasPrefix(res.Header.Get("Content-Type"), "text/") {
-			body, _ := io.ReadAll(io.LimitReader(res.Body, 1024))
-			msg = fmt.Sprintf("Status: %d; %s", res.StatusCode, string(body))
-		} else {
-			msg = fmt.Sprintf("Status: %d", res.StatusCode)
+			bbody, _ := io.ReadAll(io.LimitReader(res.Body, 1024))
+			body = string(bbody)
 		}
 
 		res.Body.Close()
 		reqCancel()
 
-		status := 404
-		if res.StatusCode >= 500 {
-			status = 500
-		}
-
-		return nil, func() {}, ierrors.New(status, msg, msgSourceImageIsUnreachable)
+		return nil, func() {}, newImageResponseStatusError(res.StatusCode, body)
 	}
 
 	return res, reqCancel, nil

+ 0 - 53
imagedata/error.go

@@ -1,53 +0,0 @@
-package imagedata
-
-import (
-	"context"
-	"errors"
-	"fmt"
-	"net/http"
-
-	"github.com/imgproxy/imgproxy/v3/ierrors"
-	"github.com/imgproxy/imgproxy/v3/security"
-)
-
-type httpError interface {
-	Timeout() bool
-}
-
-func wrapError(err error) error {
-	isTimeout := false
-
-	switch {
-	case errors.Is(err, context.DeadlineExceeded):
-		isTimeout = true
-	case errors.Is(err, context.Canceled):
-		return ierrors.New(
-			499,
-			fmt.Sprintf("The image request is cancelled: %s", err),
-			msgSourceImageIsUnreachable,
-		)
-	case errors.Is(err, security.ErrSourceAddressNotAllowed), errors.Is(err, security.ErrInvalidSourceAddress):
-		return ierrors.New(
-			404,
-			err.Error(),
-			msgSourceImageIsUnreachable,
-		)
-	default:
-		if httpErr, ok := err.(httpError); ok {
-			isTimeout = httpErr.Timeout()
-		}
-	}
-
-	if !isTimeout {
-		return err
-	}
-
-	ierr := ierrors.New(
-		http.StatusGatewayTimeout,
-		fmt.Sprintf("The image request timed out: %s", err),
-		msgSourceImageIsUnreachable,
-	)
-	ierr.Unexpected = true
-
-	return ierr
-}

+ 170 - 0
imagedata/errors.go

@@ -0,0 +1,170 @@
+package imagedata
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/imgproxy/imgproxy/v3/ierrors"
+	"github.com/imgproxy/imgproxy/v3/security"
+)
+
+type (
+	ImageRequestError         struct{ error }
+	ImageRequstSchemeError    string
+	ImagePartialResponseError string
+	ImageResponseStatusError  string
+	ImageRequestCanceledError struct{ error }
+	ImageRequestTimeoutError  struct{ error }
+
+	NotModifiedError struct {
+		headers map[string]string
+	}
+
+	httpError interface {
+		Timeout() bool
+	}
+)
+
+func newImageRequestError(err error) error {
+	return ierrors.Wrap(
+		ImageRequestError{err},
+		1,
+		ierrors.WithStatusCode(http.StatusNotFound),
+		ierrors.WithPublicMessage(msgSourceImageIsUnreachable),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e ImageRequestError) Unwrap() error {
+	return e.error
+}
+
+func newImageRequstSchemeError(scheme string) error {
+	return ierrors.Wrap(
+		ImageRequstSchemeError(fmt.Sprintf("Unknown scheme: %s", scheme)),
+		1,
+		ierrors.WithStatusCode(http.StatusNotFound),
+		ierrors.WithPublicMessage(msgSourceImageIsUnreachable),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e ImageRequstSchemeError) Error() string { return string(e) }
+
+func newImagePartialResponseError(msg string) error {
+	return ierrors.Wrap(
+		ImagePartialResponseError(msg),
+		1,
+		ierrors.WithStatusCode(http.StatusNotFound),
+		ierrors.WithPublicMessage(msgSourceImageIsUnreachable),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e ImagePartialResponseError) Error() string { return string(e) }
+
+func newImageResponseStatusError(status int, body string) error {
+	var msg string
+
+	if len(body) > 0 {
+		msg = fmt.Sprintf("Status: %d; %s", status, body)
+	} else {
+		msg = fmt.Sprintf("Status: %d", status)
+	}
+
+	statusCode := 404
+	if status >= 500 {
+		statusCode = 500
+	}
+
+	return ierrors.Wrap(
+		ImageResponseStatusError(msg),
+		1,
+		ierrors.WithStatusCode(statusCode),
+		ierrors.WithPublicMessage(msgSourceImageIsUnreachable),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e ImageResponseStatusError) Error() string { return string(e) }
+
+func newImageRequestCanceledError(err error) error {
+	return ierrors.Wrap(
+		ImageRequestCanceledError{err},
+		2,
+		ierrors.WithStatusCode(499),
+		ierrors.WithPublicMessage(msgSourceImageIsUnreachable),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e ImageRequestCanceledError) Error() string {
+	return fmt.Sprintf("The image request is cancelled: %s", e.error)
+}
+
+func (e ImageRequestCanceledError) Unwrap() error { return e.error }
+
+func newImageRequestTimeoutError(err error) error {
+	return ierrors.Wrap(
+		ImageRequestTimeoutError{err},
+		2,
+		ierrors.WithStatusCode(http.StatusGatewayTimeout),
+		ierrors.WithPublicMessage(msgSourceImageIsUnreachable),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e ImageRequestTimeoutError) Error() string {
+	return fmt.Sprintf("The image request timed out: %s", e.error)
+}
+
+func (e ImageRequestTimeoutError) Unwrap() error { return e.error }
+
+func newNotModifiedError(headers map[string]string) error {
+	return ierrors.Wrap(
+		NotModifiedError{headers},
+		1,
+		ierrors.WithStatusCode(http.StatusNotModified),
+		ierrors.WithPublicMessage("Not modified"),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e NotModifiedError) Error() string { return "Not modified" }
+
+func (e NotModifiedError) Headers() map[string]string {
+	return e.headers
+}
+
+func wrapError(err error) error {
+	isTimeout := false
+
+	var secArrdErr security.SourceAddressError
+
+	switch {
+	case errors.Is(err, context.DeadlineExceeded):
+		isTimeout = true
+	case errors.Is(err, context.Canceled):
+		return newImageRequestCanceledError(err)
+	case errors.As(err, &secArrdErr):
+		return ierrors.Wrap(
+			err,
+			1,
+			ierrors.WithStatusCode(404),
+			ierrors.WithPublicMessage(msgSourceImageIsUnreachable),
+			ierrors.WithShouldReport(false),
+		)
+	default:
+		if httpErr, ok := err.(httpError); ok {
+			isTimeout = httpErr.Timeout()
+		}
+	}
+
+	if isTimeout {
+		return newImageRequestTimeoutError(err)
+	}
+
+	return ierrors.Wrap(err, 1)
+}

+ 4 - 5
imagedata/image_data.go

@@ -133,11 +133,10 @@ func FromFile(path, desc string, secopts security.Options) (*ImageData, error) {
 func Download(ctx context.Context, imageURL, desc string, opts DownloadOptions, secopts security.Options) (*ImageData, error) {
 	imgdata, err := download(ctx, imageURL, opts, secopts)
 	if err != nil {
-		if nmErr, ok := err.(*ErrorNotModified); ok {
-			nmErr.Message = fmt.Sprintf("Can't download %s: %s", desc, nmErr.Message)
-			return nil, nmErr
-		}
-		return nil, ierrors.WrapWithPrefix(err, 1, fmt.Sprintf("Can't download %s", desc))
+		return nil, ierrors.Wrap(
+			err, 0,
+			ierrors.WithPrefix(fmt.Sprintf("Can't download %s", desc)),
+		)
 	}
 
 	return imgdata, nil

+ 9 - 11
imagedata/image_data_test.go

@@ -7,7 +7,6 @@ import (
 	"encoding/base64"
 	"fmt"
 	"io"
-	"log"
 	"net"
 	"net/http"
 	"net/http/httptest"
@@ -162,7 +161,7 @@ func (s *ImageDataTestSuite) TestDownloadStatusPartialContent() {
 
 			if tc.expectErr {
 				s.Require().Error(err)
-				s.Require().Equal(404, ierrors.Wrap(err, 0).StatusCode)
+				s.Require().Equal(404, ierrors.Wrap(err, 0).StatusCode())
 			} else {
 				s.Require().NoError(err)
 				s.Require().NotNil(imgdata)
@@ -181,7 +180,7 @@ func (s *ImageDataTestSuite) TestDownloadStatusNotFound() {
 	imgdata, err := Download(context.Background(), s.server.URL, "Test image", DownloadOptions{}, security.DefaultOptions())
 
 	s.Require().Error(err)
-	s.Require().Equal(404, ierrors.Wrap(err, 0).StatusCode)
+	s.Require().Equal(404, ierrors.Wrap(err, 0).StatusCode())
 	s.Require().Nil(imgdata)
 }
 
@@ -193,7 +192,7 @@ func (s *ImageDataTestSuite) TestDownloadStatusForbidden() {
 	imgdata, err := Download(context.Background(), s.server.URL, "Test image", DownloadOptions{}, security.DefaultOptions())
 
 	s.Require().Error(err)
-	s.Require().Equal(404, ierrors.Wrap(err, 0).StatusCode)
+	s.Require().Equal(404, ierrors.Wrap(err, 0).StatusCode())
 	s.Require().Nil(imgdata)
 }
 
@@ -205,7 +204,7 @@ func (s *ImageDataTestSuite) TestDownloadStatusInternalServerError() {
 	imgdata, err := Download(context.Background(), s.server.URL, "Test image", DownloadOptions{}, security.DefaultOptions())
 
 	s.Require().Error(err)
-	s.Require().Equal(500, ierrors.Wrap(err, 0).StatusCode)
+	s.Require().Equal(500, ierrors.Wrap(err, 0).StatusCode())
 	s.Require().Nil(imgdata)
 }
 
@@ -219,7 +218,7 @@ func (s *ImageDataTestSuite) TestDownloadUnreachable() {
 	imgdata, err := Download(context.Background(), serverURL, "Test image", DownloadOptions{}, security.DefaultOptions())
 
 	s.Require().Error(err)
-	s.Require().Equal(500, ierrors.Wrap(err, 0).StatusCode)
+	s.Require().Equal(500, ierrors.Wrap(err, 0).StatusCode())
 	s.Require().Nil(imgdata)
 }
 
@@ -229,18 +228,17 @@ func (s *ImageDataTestSuite) TestDownloadInvalidImage() {
 	imgdata, err := Download(context.Background(), s.server.URL, "Test image", DownloadOptions{}, security.DefaultOptions())
 
 	s.Require().Error(err)
-	s.Require().Equal(422, ierrors.Wrap(err, 0).StatusCode)
+	s.Require().Equal(422, ierrors.Wrap(err, 0).StatusCode())
 	s.Require().Nil(imgdata)
 }
 
 func (s *ImageDataTestSuite) TestDownloadSourceAddressNotAllowed() {
-	log.Printf("Server URL: %s", s.server.URL)
 	config.AllowLoopbackSourceAddresses = false
 
 	imgdata, err := Download(context.Background(), s.server.URL, "Test image", DownloadOptions{}, security.DefaultOptions())
 
 	s.Require().Error(err)
-	s.Require().Equal(404, ierrors.Wrap(err, 0).StatusCode)
+	s.Require().Equal(404, ierrors.Wrap(err, 0).StatusCode())
 	s.Require().Nil(imgdata)
 }
 
@@ -250,7 +248,7 @@ func (s *ImageDataTestSuite) TestDownloadImageTooLarge() {
 	imgdata, err := Download(context.Background(), s.server.URL, "Test image", DownloadOptions{}, security.DefaultOptions())
 
 	s.Require().Error(err)
-	s.Require().Equal(422, ierrors.Wrap(err, 0).StatusCode)
+	s.Require().Equal(422, ierrors.Wrap(err, 0).StatusCode())
 	s.Require().Nil(imgdata)
 }
 
@@ -260,7 +258,7 @@ func (s *ImageDataTestSuite) TestDownloadImageFileTooLarge() {
 	imgdata, err := Download(context.Background(), s.server.URL, "Test image", DownloadOptions{}, security.DefaultOptions())
 
 	s.Require().Error(err)
-	s.Require().Equal(422, ierrors.Wrap(err, 0).StatusCode)
+	s.Require().Equal(422, ierrors.Wrap(err, 0).StatusCode())
 	s.Require().Nil(imgdata)
 }
 

+ 0 - 7
imagedata/read.go

@@ -8,13 +8,10 @@ import (
 	"github.com/imgproxy/imgproxy/v3/bufpool"
 	"github.com/imgproxy/imgproxy/v3/bufreader"
 	"github.com/imgproxy/imgproxy/v3/config"
-	"github.com/imgproxy/imgproxy/v3/ierrors"
 	"github.com/imgproxy/imgproxy/v3/imagemeta"
 	"github.com/imgproxy/imgproxy/v3/security"
 )
 
-var ErrSourceImageTypeNotSupported = ierrors.New(422, "Source image type not supported", "Invalid source image")
-
 var downloadBufPool *bufpool.Pool
 
 func initRead() {
@@ -38,10 +35,6 @@ func readAndCheckImage(r io.Reader, contentLength int, secopts security.Options)
 		buf.Reset()
 		cancel()
 
-		if err == imagemeta.ErrFormat {
-			return nil, ErrSourceImageTypeNotSupported
-		}
-
 		return nil, wrapError(err)
 	}
 

+ 1 - 5
imagemeta/bmp.go

@@ -10,10 +10,6 @@ import (
 
 var bmpMagick = []byte("BM")
 
-type BmpFormatError string
-
-func (e BmpFormatError) Error() string { return "invalid BMP format: " + string(e) }
-
 func DecodeBmpMeta(r io.Reader) (Meta, error) {
 	var tmp [26]byte
 
@@ -22,7 +18,7 @@ func DecodeBmpMeta(r io.Reader) (Meta, error) {
 	}
 
 	if !bytes.Equal(tmp[:2], bmpMagick) {
-		return nil, BmpFormatError("malformed header")
+		return nil, newFormatError("BMP", "malformed header")
 	}
 
 	infoSize := binary.LittleEndian.Uint32(tmp[14:18])

+ 37 - 0
imagemeta/errors.go

@@ -0,0 +1,37 @@
+package imagemeta
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/imgproxy/imgproxy/v3/ierrors"
+)
+
+type (
+	UnknownFormatError struct{}
+	FormatError        string
+)
+
+func newUnknownFormatError() error {
+	return ierrors.Wrap(
+		UnknownFormatError{},
+		1,
+		ierrors.WithStatusCode(http.StatusUnprocessableEntity),
+		ierrors.WithPublicMessage("Invalid source image"),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e UnknownFormatError) Error() string { return "Source image type not supported" }
+
+func newFormatError(format, msg string) error {
+	return ierrors.Wrap(
+		FormatError(fmt.Sprintf("Invalid %s file: %s", format, msg)),
+		1,
+		ierrors.WithStatusCode(http.StatusUnprocessableEntity),
+		ierrors.WithPublicMessage("Invalid source image"),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e FormatError) Error() string { return string(e) }

+ 11 - 10
imagemeta/heif.go

@@ -35,11 +35,11 @@ type heifData struct {
 
 func (d *heifData) Meta() (*meta, error) {
 	if d.Format == imagetype.Unknown {
-		return nil, errors.New("Invalid HEIF file: format data wasn't found")
+		return nil, newFormatError("HEIF", "format data wasn't found")
 	}
 
 	if len(d.Sizes) == 0 {
-		return nil, errors.New("Invalid HEIF file: dimensions data wasn't found")
+		return nil, newFormatError("HEIF", "dimensions data wasn't found")
 	}
 
 	bestSize := slices.MaxFunc(d.Sizes, func(a, b heifSize) int {
@@ -64,6 +64,7 @@ func heifReadN(r io.Reader, n uint64) (b []byte, err error) {
 
 	b = make([]byte, n)
 	_, err = io.ReadFull(r, b)
+
 	return
 }
 
@@ -107,7 +108,7 @@ func heifReadBoxHeader(r io.Reader) (boxType string, boxDataSize uint64, err err
 	}
 
 	if boxDataSize < heifBoxHeaderSize || boxDataSize > math.MaxInt64 {
-		return "", 0, errors.New("Invalid box data size")
+		return "", 0, newFormatError("HEIF", "invalid box data size")
 	}
 
 	boxDataSize -= headerSize
@@ -131,7 +132,7 @@ func heifAssignFormat(d *heifData, brand []byte) bool {
 
 func heifReadFtyp(d *heifData, r io.Reader, boxDataSize uint64) error {
 	if boxDataSize < 8 {
-		return errors.New("Invalid ftyp data")
+		return newFormatError("HEIF", "invalid ftyp data")
 	}
 
 	data, err := heifReadN(r, boxDataSize)
@@ -151,12 +152,12 @@ func heifReadFtyp(d *heifData, r io.Reader, boxDataSize uint64) error {
 		}
 	}
 
-	return errors.New("Image is not compatible with heic/avif")
+	return newFormatError("HEIF", "image is not compatible with heic/avif")
 }
 
 func heifReadMeta(d *heifData, r io.Reader, boxDataSize uint64) error {
 	if boxDataSize < 4 {
-		return errors.New("Invalid meta data")
+		return newFormatError("HEIF", "invalid meta data")
 	}
 
 	data, err := heifReadN(r, boxDataSize)
@@ -165,7 +166,7 @@ func heifReadMeta(d *heifData, r io.Reader, boxDataSize uint64) error {
 	}
 
 	if boxDataSize > 4 {
-		if err := heifReadBoxes(d, bytes.NewBuffer(data[4:])); err != nil && err != io.EOF {
+		if err := heifReadBoxes(d, bytes.NewBuffer(data[4:])); err != nil && !errors.Is(err, io.EOF) {
 			return err
 		}
 	}
@@ -175,7 +176,7 @@ func heifReadMeta(d *heifData, r io.Reader, boxDataSize uint64) error {
 
 func heifReadHldr(r io.Reader, boxDataSize uint64) error {
 	if boxDataSize < 12 {
-		return errors.New("Invalid hdlr data")
+		return newFormatError("HEIF", "invalid hdlr data")
 	}
 
 	data, err := heifReadN(r, boxDataSize)
@@ -192,7 +193,7 @@ func heifReadHldr(r io.Reader, boxDataSize uint64) error {
 
 func heifReadIspe(r io.Reader, boxDataSize uint64) (w, h int64, err error) {
 	if boxDataSize < 12 {
-		return 0, 0, errors.New("Invalid ispe data")
+		return 0, 0, newFormatError("HEIF", "invalid ispe data")
 	}
 
 	data, err := heifReadN(r, boxDataSize)
@@ -230,7 +231,7 @@ func heifReadBoxes(d *heifData, r io.Reader) error {
 				return err
 			}
 
-			if err := heifReadBoxes(d, bytes.NewBuffer(data)); err != nil && err != io.EOF {
+			if err := heifReadBoxes(d, bytes.NewBuffer(data)); err != nil && !errors.Is(err, io.EOF) {
 				return err
 			}
 		case "ispe":

+ 2 - 4
imagemeta/image_meta.go

@@ -48,8 +48,6 @@ type reader interface {
 var (
 	formatsMu     sync.Mutex
 	atomicFormats atomic.Value
-
-	ErrFormat = errors.New("unknown image format")
 )
 
 func asReader(r io.Reader) reader {
@@ -84,7 +82,7 @@ func DecodeMeta(r io.Reader) (Meta, error) {
 	formats, _ := atomicFormats.Load().([]format)
 
 	for _, f := range formats {
-		if b, err := rr.Peek(len(f.magic)); err == nil || err == io.EOF {
+		if b, err := rr.Peek(len(f.magic)); err == nil || errors.Is(err, io.EOF) {
 			if matchMagic(f.magic, b) {
 				return f.decodeMeta(rr)
 			}
@@ -97,5 +95,5 @@ func DecodeMeta(r io.Reader) (Meta, error) {
 		return &meta{format: imagetype.SVG, width: 1, height: 1}, nil
 	}
 
-	return nil, ErrFormat
+	return nil, newUnknownFormatError()
 }

+ 21 - 0
imagemeta/iptc/errors.go

@@ -0,0 +1,21 @@
+package iptc
+
+import (
+	"fmt"
+
+	"github.com/imgproxy/imgproxy/v3/ierrors"
+)
+
+type IptcError string
+
+func newIptcError(format string, args ...interface{}) error {
+	return ierrors.Wrap(
+		IptcError(fmt.Sprintf(format, args...)),
+		1,
+		ierrors.WithStatusCode(422),
+		ierrors.WithPublicMessage("Invalid IPTC data"),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e IptcError) Error() string { return string(e) }

+ 6 - 12
imagemeta/iptc/iptc.go

@@ -4,28 +4,22 @@ import (
 	"bytes"
 	"encoding/binary"
 	"encoding/json"
-	"errors"
-	"fmt"
 	"math"
 )
 
-var (
-	iptcTagHeader = byte(0x1c)
-
-	errInvalidDataSize = errors.New("invalid IPTC data size")
-)
+var iptcTagHeader = byte(0x1c)
 
 type IptcMap map[TagKey][]TagValue
 
 func (m IptcMap) AddTag(key TagKey, data []byte) error {
 	info, infoFound := tagInfoMap[key]
 	if !infoFound {
-		return fmt.Errorf("unknown tag %d:%d", key.RecordID, key.TagID)
+		return newIptcError("unknown tag %d:%d", key.RecordID, key.TagID)
 	}
 
 	dataSize := len(data)
 	if dataSize < info.MinSize || dataSize > info.MaxSize {
-		return fmt.Errorf("invalid tag data size. Min: %d, Max: %d, Has: %d", info.MinSize, info.MaxSize, dataSize)
+		return newIptcError("invalid tag data size. Min: %d, Max: %d, Has: %d", info.MinSize, info.MaxSize, dataSize)
 	}
 
 	value := TagValue{info.Format, data}
@@ -89,17 +83,17 @@ func Parse(data []byte, m IptcMap) error {
 			case 4:
 				dataSize32 := uint32(0)
 				if err := binary.Read(buf, binary.BigEndian, &dataSize32); err != nil {
-					return fmt.Errorf("%s: %s", errInvalidDataSize, err)
+					return newIptcError("invalid IPTC data size: %s", err)
 				}
 				dataSize = int(dataSize32)
 			case 8:
 				dataSize64 := uint64(0)
 				if err := binary.Read(buf, binary.BigEndian, &dataSize64); err != nil {
-					return fmt.Errorf("%s: %s", errInvalidDataSize, err)
+					return newIptcError("invalid IPTC data size: %s", err)
 				}
 				dataSize = int(dataSize64)
 			default:
-				return errInvalidDataSize
+				return newIptcError("invalid IPTC data size")
 			}
 		}
 

+ 1 - 2
imagemeta/iptc/tags.go

@@ -3,7 +3,6 @@ package iptc
 import (
 	"encoding/binary"
 	"encoding/json"
-	"fmt"
 	"math"
 )
 
@@ -425,7 +424,7 @@ var tagInfoMap = map[TagKey]TagInfo{
 func GetTagInfo(key TagKey) (TagInfo, error) {
 	info, infoFound := tagInfoMap[key]
 	if !infoFound {
-		return TagInfo{}, fmt.Errorf("unknown tag %d:%d", key.RecordID, key.TagID)
+		return TagInfo{}, newIptcError("unknown tag %d:%d", key.RecordID, key.TagID)
 	}
 	return info, nil
 }

+ 5 - 9
imagemeta/jpeg.go

@@ -42,10 +42,6 @@ func asJpegReader(r io.Reader) jpegReader {
 	return bufio.NewReader(r)
 }
 
-type JpegFormatError string
-
-func (e JpegFormatError) Error() string { return "invalid JPEG format: " + string(e) }
-
 func DecodeJpegMeta(rr io.Reader) (Meta, error) {
 	var tmp [512]byte
 
@@ -55,7 +51,7 @@ func DecodeJpegMeta(rr io.Reader) (Meta, error) {
 		return nil, err
 	}
 	if tmp[0] != 0xff || tmp[1] != jpegSoiMarker {
-		return nil, JpegFormatError("missing SOI marker")
+		return nil, newFormatError("JPEG", "missing SOI marker")
 	}
 
 	for {
@@ -89,11 +85,11 @@ func DecodeJpegMeta(rr io.Reader) (Meta, error) {
 		}
 
 		if marker == jpegEoiMarker { // End Of Image.
-			return nil, JpegFormatError("missing SOF marker")
+			return nil, newFormatError("JPEG", "missing SOF marker")
 		}
 
 		if marker == jpegSoiMarker {
-			return nil, JpegFormatError("two SOI markers")
+			return nil, newFormatError("JPEG", "two SOI markers")
 		}
 
 		if jpegRst0Marker <= marker && marker <= jpegRst7Marker {
@@ -118,7 +114,7 @@ func DecodeJpegMeta(rr io.Reader) (Meta, error) {
 			}
 			// We only support 8-bit precision.
 			if tmp[0] != 8 {
-				return nil, JpegFormatError("unsupported precision")
+				return nil, newFormatError("JPEG", "unsupported precision")
 			}
 
 			return &meta{
@@ -128,7 +124,7 @@ func DecodeJpegMeta(rr io.Reader) (Meta, error) {
 			}, nil
 
 		case jpegSosMarker:
-			return nil, JpegFormatError("missing SOF marker")
+			return nil, newFormatError("JPEG", "missing SOF marker")
 		}
 
 		// Skip any other uninteresting segments

+ 6 - 10
imagemeta/jxl.go

@@ -54,13 +54,9 @@ func (br *jxlBitReader) Read(n uint64) (uint64, error) {
 	return res, nil
 }
 
-type JxlFormatError string
-
-func (e JxlFormatError) Error() string { return "invalid JPEG XL format: " + string(e) }
-
 func jxlReadJxlc(r io.Reader, boxDataSize uint64) ([]byte, error) {
 	if boxDataSize < jxlCodestreamHeaderMinSize {
-		return nil, JxlFormatError("invalid codestream box")
+		return nil, newFormatError("JPEG XL", "invalid codestream box")
 	}
 
 	toRead := boxDataSize
@@ -73,7 +69,7 @@ func jxlReadJxlc(r io.Reader, boxDataSize uint64) ([]byte, error) {
 
 func jxlReadJxlp(r io.Reader, boxDataSize uint64, codestream []byte) ([]byte, bool, error) {
 	if boxDataSize < 4 {
-		return nil, false, JxlFormatError("invalid jxlp box")
+		return nil, false, newFormatError("JPEG XL", "invalid jxlp box")
 	}
 
 	jxlpInd, err := heifReadN(r, 4)
@@ -139,7 +135,7 @@ func jxlFindCodestream(r io.Reader) ([]byte, error) {
 			}
 
 			if last {
-				return nil, JxlFormatError("invalid codestream box")
+				return nil, newFormatError("JPEG XL", "invalid codestream box")
 			}
 
 		// Skip other boxes
@@ -170,11 +166,11 @@ func jxlParseSize(br *jxlBitReader, small bool) (uint64, error) {
 
 func jxlDecodeCodestreamHeader(buf []byte) (width, height uint64, err error) {
 	if len(buf) < jxlCodestreamHeaderMinSize {
-		return 0, 0, JxlFormatError("invalid codestream header")
+		return 0, 0, newFormatError("JPEG XL", "invalid codestream header")
 	}
 
 	if !bytes.Equal(buf[0:2], jxlCodestreamMarker) {
-		return 0, 0, JxlFormatError("missing codestream marker")
+		return 0, 0, newFormatError("JPEG XL", "missing codestream marker")
 	}
 
 	br := NewJxlBitReader(buf[2:])
@@ -230,7 +226,7 @@ func DecodeJxlMeta(r io.Reader) (Meta, error) {
 		}
 
 		if !bytes.Equal(tmp[0:12], jxlISOBMFFMarker) {
-			return nil, JxlFormatError("invalid header")
+			return nil, newFormatError("JPEG XL", "invalid header")
 		}
 
 		codestream, err = jxlFindCodestream(r)

+ 2 - 7
imagemeta/photoshop/photoshop.go

@@ -3,14 +3,11 @@ package photoshop
 import (
 	"bytes"
 	"encoding/binary"
-	"errors"
 )
 
 var (
 	ps3Header      = []byte("Photoshop 3.0\x00")
 	ps3BlockHeader = []byte("8BIM")
-
-	errInvalidPS3Header = errors.New("invalid Photoshop 3.0 header")
 )
 
 const (
@@ -20,11 +17,11 @@ const (
 
 type PhotoshopMap map[string][]byte
 
-func Parse(data []byte, m PhotoshopMap) error {
+func Parse(data []byte, m PhotoshopMap) {
 	buf := bytes.NewBuffer(data)
 
 	if !bytes.Equal(buf.Next(14), ps3Header) {
-		return errInvalidPS3Header
+		return
 	}
 
 	// Read blocks
@@ -58,8 +55,6 @@ func Parse(data []byte, m PhotoshopMap) error {
 
 		m[string(resoureceID)] = blockData
 	}
-
-	return nil
 }
 
 func (m PhotoshopMap) Dump() []byte {

+ 1 - 5
imagemeta/png.go

@@ -10,10 +10,6 @@ import (
 
 var pngMagick = []byte("\x89PNG\r\n\x1a\n")
 
-type PngFormatError string
-
-func (e PngFormatError) Error() string { return "invalid PNG format: " + string(e) }
-
 func DecodePngMeta(r io.Reader) (Meta, error) {
 	var tmp [16]byte
 
@@ -22,7 +18,7 @@ func DecodePngMeta(r io.Reader) (Meta, error) {
 	}
 
 	if !bytes.Equal(pngMagick, tmp[:8]) {
-		return nil, PngFormatError("not a PNG image")
+		return nil, newFormatError("PNG", "not a PNG image")
 	}
 
 	if _, err := io.ReadFull(r, tmp[:]); err != nil {

+ 3 - 7
imagemeta/tiff.go

@@ -35,10 +35,6 @@ func asTiffReader(r io.Reader) tiffReader {
 	return bufio.NewReader(r)
 }
 
-type TiffFormatError string
-
-func (e TiffFormatError) Error() string { return "invalid TIFF format: " + string(e) }
-
 func DecodeTiffMeta(rr io.Reader) (Meta, error) {
 	var (
 		tmp       [12]byte
@@ -57,7 +53,7 @@ func DecodeTiffMeta(rr io.Reader) (Meta, error) {
 	case bytes.Equal(tiffBeHeader, tmp[0:4]):
 		byteOrder = binary.BigEndian
 	default:
-		return nil, TiffFormatError("malformed header")
+		return nil, newFormatError("TIFF", "malformed header")
 	}
 
 	ifdOffset := int(byteOrder.Uint32(tmp[4:8]))
@@ -96,7 +92,7 @@ func DecodeTiffMeta(rr io.Reader) (Meta, error) {
 		case tiffDtLong:
 			value = int(byteOrder.Uint32(tmp[8:12]))
 		default:
-			return nil, TiffFormatError("unsupported IFD entry datatype")
+			return nil, newFormatError("TIFF", "unsupported IFD entry datatype")
 		}
 
 		if tag == tiffImageWidth {
@@ -114,7 +110,7 @@ func DecodeTiffMeta(rr io.Reader) (Meta, error) {
 		}
 	}
 
-	return nil, TiffFormatError("image dimensions are not specified")
+	return nil, newFormatError("TIFF", "image dimensions are not specified")
 }
 
 func init() {

+ 5 - 8
imagemeta/webp.go

@@ -7,7 +7,6 @@
 package imagemeta
 
 import (
-	"errors"
 	"io"
 
 	"github.com/imgproxy/imgproxy/v3/imagetype"
@@ -16,8 +15,6 @@ import (
 	"golang.org/x/image/vp8l"
 )
 
-var ErrWebpInvalidFormat = errors.New("webp: invalid format")
-
 var (
 	webpFccALPH = riff.FourCC{'A', 'L', 'P', 'H'}
 	webpFccVP8  = riff.FourCC{'V', 'P', '8', ' '}
@@ -32,7 +29,7 @@ func DecodeWebpMeta(r io.Reader) (Meta, error) {
 		return nil, err
 	}
 	if formType != webpFccWEBP {
-		return nil, ErrWebpInvalidFormat
+		return nil, newFormatError("WEBP", "invalid form type")
 	}
 
 	var buf [10]byte
@@ -40,7 +37,7 @@ func DecodeWebpMeta(r io.Reader) (Meta, error) {
 	for {
 		chunkID, chunkLen, chunkData, err := riffReader.Next()
 		if err == io.EOF {
-			err = ErrWebpInvalidFormat
+			err = newFormatError("WEBP", "no VP8, VP8L or VP8X chunk found")
 		}
 		if err != nil {
 			return nil, err
@@ -51,7 +48,7 @@ func DecodeWebpMeta(r io.Reader) (Meta, error) {
 			// Ignore
 		case webpFccVP8:
 			if int32(chunkLen) < 0 {
-				return nil, ErrWebpInvalidFormat
+				return nil, newFormatError("WEBP", "invalid chunk length")
 			}
 
 			d := vp8.NewDecoder()
@@ -79,7 +76,7 @@ func DecodeWebpMeta(r io.Reader) (Meta, error) {
 
 		case webpFccVP8X:
 			if chunkLen != 10 {
-				return nil, ErrWebpInvalidFormat
+				return nil, newFormatError("WEBP", "invalid chunk length")
 			}
 
 			if _, err := io.ReadFull(chunkData, buf[:10]); err != nil {
@@ -96,7 +93,7 @@ func DecodeWebpMeta(r io.Reader) (Meta, error) {
 			}, nil
 
 		default:
-			return nil, ErrWebpInvalidFormat
+			return nil, newFormatError("WEBP", "unknown chunk")
 		}
 	}
 }

+ 50 - 0
options/errors.go

@@ -0,0 +1,50 @@
+package options
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/imgproxy/imgproxy/v3/ierrors"
+)
+
+type (
+	InvalidURLError     string
+	UnknownOptionError  string
+	OptionArgumentError string
+)
+
+func newInvalidURLError(format string, args ...interface{}) error {
+	return ierrors.Wrap(
+		InvalidURLError(fmt.Sprintf(format, args...)),
+		1,
+		ierrors.WithStatusCode(http.StatusNotFound),
+		ierrors.WithPublicMessage("Invalid URL"),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e InvalidURLError) Error() string { return string(e) }
+
+func newUnknownOptionError(kind, opt string) error {
+	return ierrors.Wrap(
+		UnknownOptionError(fmt.Sprintf("Unknown %s option %s", kind, opt)),
+		1,
+		ierrors.WithStatusCode(http.StatusNotFound),
+		ierrors.WithPublicMessage("Invalid URL"),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e UnknownOptionError) Error() string { return string(e) }
+
+func newOptionArgumentError(format string, args ...interface{}) error {
+	return ierrors.Wrap(
+		OptionArgumentError(fmt.Sprintf(format, args...)),
+		1,
+		ierrors.WithStatusCode(http.StatusNotFound),
+		ierrors.WithPublicMessage("Invalid URL"),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e OptionArgumentError) Error() string { return string(e) }

+ 83 - 91
options/processing_options.go

@@ -2,8 +2,6 @@ package options
 
 import (
 	"encoding/base64"
-	"errors"
-	"fmt"
 	"net/http"
 	"slices"
 	"strconv"
@@ -23,8 +21,6 @@ import (
 
 const maxClientHintDPR = 8
 
-var errExpiredURL = errors.New("Expired URL")
-
 type ExtendOptions struct {
 	Enabled bool
 	Gravity GravityOptions
@@ -199,7 +195,7 @@ func parseDimension(d *int, name, arg string) error {
 	if v, err := strconv.Atoi(arg); err == nil && v >= 0 {
 		*d = v
 	} else {
-		return fmt.Errorf("Invalid %s: %s", name, arg)
+		return newOptionArgumentError("Invalid %s: %s", name, arg)
 	}
 
 	return nil
@@ -225,32 +221,32 @@ func parseGravity(g *GravityOptions, name string, args []string, allowedTypes []
 	if t, ok := gravityTypes[args[0]]; ok && slices.Contains(allowedTypes, t) {
 		g.Type = t
 	} else {
-		return fmt.Errorf("Invalid %s: %s", name, args[0])
+		return newOptionArgumentError("Invalid %s: %s", name, args[0])
 	}
 
 	switch g.Type {
 	case GravitySmart:
 		if nArgs > 1 {
-			return fmt.Errorf("Invalid %s arguments: %v", name, args)
+			return newOptionArgumentError("Invalid %s arguments: %v", name, args)
 		}
 		g.X, g.Y = 0.0, 0.0
 
 	case GravityFocusPoint:
 		if nArgs != 3 {
-			return fmt.Errorf("Invalid %s arguments: %v", name, args)
+			return newOptionArgumentError("Invalid %s arguments: %v", name, args)
 		}
 		fallthrough
 
 	default:
 		if nArgs > 3 {
-			return fmt.Errorf("Invalid %s arguments: %v", name, args)
+			return newOptionArgumentError("Invalid %s arguments: %v", name, args)
 		}
 
 		if nArgs > 1 {
 			if x, err := strconv.ParseFloat(args[1], 64); err == nil && isGravityOffcetValid(g.Type, x) {
 				g.X = x
 			} else {
-				return fmt.Errorf("Invalid %s X: %s", name, args[1])
+				return newOptionArgumentError("Invalid %s X: %s", name, args[1])
 			}
 		}
 
@@ -258,7 +254,7 @@ func parseGravity(g *GravityOptions, name string, args []string, allowedTypes []
 			if y, err := strconv.ParseFloat(args[2], 64); err == nil && isGravityOffcetValid(g.Type, y) {
 				g.Y = y
 			} else {
-				return fmt.Errorf("Invalid %s Y: %s", name, args[2])
+				return newOptionArgumentError("Invalid %s Y: %s", name, args[2])
 			}
 		}
 	}
@@ -268,7 +264,7 @@ func parseGravity(g *GravityOptions, name string, args []string, allowedTypes []
 
 func parseExtend(opts *ExtendOptions, name string, args []string) error {
 	if len(args) > 4 {
-		return fmt.Errorf("Invalid %s arguments: %v", name, args)
+		return newOptionArgumentError("Invalid %s arguments: %v", name, args)
 	}
 
 	opts.Enabled = parseBoolOption(args[0])
@@ -282,7 +278,7 @@ func parseExtend(opts *ExtendOptions, name string, args []string) error {
 
 func applyWidthOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid width arguments: %v", args)
+		return newOptionArgumentError("Invalid width arguments: %v", args)
 	}
 
 	return parseDimension(&po.Width, "width", args[0])
@@ -290,7 +286,7 @@ func applyWidthOption(po *ProcessingOptions, args []string) error {
 
 func applyHeightOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid height arguments: %v", args)
+		return newOptionArgumentError("Invalid height arguments: %v", args)
 	}
 
 	return parseDimension(&po.Height, "height", args[0])
@@ -298,7 +294,7 @@ func applyHeightOption(po *ProcessingOptions, args []string) error {
 
 func applyMinWidthOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid min width arguments: %v", args)
+		return newOptionArgumentError("Invalid min width arguments: %v", args)
 	}
 
 	return parseDimension(&po.MinWidth, "min width", args[0])
@@ -306,7 +302,7 @@ func applyMinWidthOption(po *ProcessingOptions, args []string) error {
 
 func applyMinHeightOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid min height arguments: %v", args)
+		return newOptionArgumentError("Invalid min height arguments: %v", args)
 	}
 
 	return parseDimension(&po.MinHeight, " min height", args[0])
@@ -314,7 +310,7 @@ func applyMinHeightOption(po *ProcessingOptions, args []string) error {
 
 func applyEnlargeOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid enlarge arguments: %v", args)
+		return newOptionArgumentError("Invalid enlarge arguments: %v", args)
 	}
 
 	po.Enlarge = parseBoolOption(args[0])
@@ -332,7 +328,7 @@ func applyExtendAspectRatioOption(po *ProcessingOptions, args []string) error {
 
 func applySizeOption(po *ProcessingOptions, args []string) (err error) {
 	if len(args) > 7 {
-		return fmt.Errorf("Invalid size arguments: %v", args)
+		return newOptionArgumentError("Invalid size arguments: %v", args)
 	}
 
 	if len(args) >= 1 && len(args[0]) > 0 {
@@ -364,13 +360,13 @@ func applySizeOption(po *ProcessingOptions, args []string) (err error) {
 
 func applyResizingTypeOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid resizing type arguments: %v", args)
+		return newOptionArgumentError("Invalid resizing type arguments: %v", args)
 	}
 
 	if r, ok := resizeTypes[args[0]]; ok {
 		po.ResizingType = r
 	} else {
-		return fmt.Errorf("Invalid resize type: %s", args[0])
+		return newOptionArgumentError("Invalid resize type: %s", args[0])
 	}
 
 	return nil
@@ -378,7 +374,7 @@ func applyResizingTypeOption(po *ProcessingOptions, args []string) error {
 
 func applyResizeOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 8 {
-		return fmt.Errorf("Invalid resize arguments: %v", args)
+		return newOptionArgumentError("Invalid resize arguments: %v", args)
 	}
 
 	if len(args[0]) > 0 {
@@ -400,21 +396,21 @@ func applyZoomOption(po *ProcessingOptions, args []string) error {
 	nArgs := len(args)
 
 	if nArgs > 2 {
-		return fmt.Errorf("Invalid zoom arguments: %v", args)
+		return newOptionArgumentError("Invalid zoom arguments: %v", args)
 	}
 
 	if z, err := strconv.ParseFloat(args[0], 64); err == nil && z > 0 {
 		po.ZoomWidth = z
 		po.ZoomHeight = z
 	} else {
-		return fmt.Errorf("Invalid zoom value: %s", args[0])
+		return newOptionArgumentError("Invalid zoom value: %s", args[0])
 	}
 
 	if nArgs > 1 {
 		if z, err := strconv.ParseFloat(args[1], 64); err == nil && z > 0 {
 			po.ZoomHeight = z
 		} else {
-			return fmt.Errorf("Invalid zoom value: %s", args[0])
+			return newOptionArgumentError("Invalid zoom value: %s", args[0])
 		}
 	}
 
@@ -423,13 +419,13 @@ func applyZoomOption(po *ProcessingOptions, args []string) error {
 
 func applyDprOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid dpr arguments: %v", args)
+		return newOptionArgumentError("Invalid dpr arguments: %v", args)
 	}
 
 	if d, err := strconv.ParseFloat(args[0], 64); err == nil && d > 0 {
 		po.Dpr = d
 	} else {
-		return fmt.Errorf("Invalid dpr: %s", args[0])
+		return newOptionArgumentError("Invalid dpr: %s", args[0])
 	}
 
 	return nil
@@ -443,14 +439,14 @@ func applyCropOption(po *ProcessingOptions, args []string) error {
 	if w, err := strconv.ParseFloat(args[0], 64); err == nil && w >= 0 {
 		po.Crop.Width = w
 	} else {
-		return fmt.Errorf("Invalid crop width: %s", args[0])
+		return newOptionArgumentError("Invalid crop width: %s", args[0])
 	}
 
 	if len(args) > 1 {
 		if h, err := strconv.ParseFloat(args[1], 64); err == nil && h >= 0 {
 			po.Crop.Height = h
 		} else {
-			return fmt.Errorf("Invalid crop height: %s", args[1])
+			return newOptionArgumentError("Invalid crop height: %s", args[1])
 		}
 	}
 
@@ -465,7 +461,7 @@ func applyPaddingOption(po *ProcessingOptions, args []string) error {
 	nArgs := len(args)
 
 	if nArgs < 1 || nArgs > 4 {
-		return fmt.Errorf("Invalid padding arguments: %v", args)
+		return newOptionArgumentError("Invalid padding arguments: %v", args)
 	}
 
 	po.Padding.Enabled = true
@@ -509,14 +505,14 @@ func applyTrimOption(po *ProcessingOptions, args []string) error {
 	nArgs := len(args)
 
 	if nArgs > 4 {
-		return fmt.Errorf("Invalid trim arguments: %v", args)
+		return newOptionArgumentError("Invalid trim arguments: %v", args)
 	}
 
 	if t, err := strconv.ParseFloat(args[0], 64); err == nil && t >= 0 {
 		po.Trim.Enabled = true
 		po.Trim.Threshold = t
 	} else {
-		return fmt.Errorf("Invalid trim threshold: %s", args[0])
+		return newOptionArgumentError("Invalid trim threshold: %s", args[0])
 	}
 
 	if nArgs > 1 && len(args[1]) > 0 {
@@ -524,7 +520,7 @@ func applyTrimOption(po *ProcessingOptions, args []string) error {
 			po.Trim.Color = c
 			po.Trim.Smart = false
 		} else {
-			return fmt.Errorf("Invalid trim color: %s", args[1])
+			return newOptionArgumentError("Invalid trim color: %s", args[1])
 		}
 	}
 
@@ -541,13 +537,13 @@ func applyTrimOption(po *ProcessingOptions, args []string) error {
 
 func applyRotateOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid rotate arguments: %v", args)
+		return newOptionArgumentError("Invalid rotate arguments: %v", args)
 	}
 
 	if r, err := strconv.Atoi(args[0]); err == nil && r%90 == 0 {
 		po.Rotate = r
 	} else {
-		return fmt.Errorf("Invalid rotation angle: %s", args[0])
+		return newOptionArgumentError("Invalid rotation angle: %s", args[0])
 	}
 
 	return nil
@@ -555,13 +551,13 @@ func applyRotateOption(po *ProcessingOptions, args []string) error {
 
 func applyQualityOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid quality arguments: %v", args)
+		return newOptionArgumentError("Invalid quality arguments: %v", args)
 	}
 
 	if q, err := strconv.Atoi(args[0]); err == nil && q >= 0 && q <= 100 {
 		po.Quality = q
 	} else {
-		return fmt.Errorf("Invalid quality: %s", args[0])
+		return newOptionArgumentError("Invalid quality: %s", args[0])
 	}
 
 	return nil
@@ -570,19 +566,19 @@ func applyQualityOption(po *ProcessingOptions, args []string) error {
 func applyFormatQualityOption(po *ProcessingOptions, args []string) error {
 	argsLen := len(args)
 	if len(args)%2 != 0 {
-		return fmt.Errorf("Missing quality for: %s", args[argsLen-1])
+		return newOptionArgumentError("Missing quality for: %s", args[argsLen-1])
 	}
 
 	for i := 0; i < argsLen; i += 2 {
 		f, ok := imagetype.Types[args[i]]
 		if !ok {
-			return fmt.Errorf("Invalid image format: %s", args[i])
+			return newOptionArgumentError("Invalid image format: %s", args[i])
 		}
 
 		if q, err := strconv.Atoi(args[i+1]); err == nil && q >= 0 && q <= 100 {
 			po.FormatQuality[f] = q
 		} else {
-			return fmt.Errorf("Invalid quality for %s: %s", args[i], args[i+1])
+			return newOptionArgumentError("Invalid quality for %s: %s", args[i], args[i+1])
 		}
 	}
 
@@ -591,13 +587,13 @@ func applyFormatQualityOption(po *ProcessingOptions, args []string) error {
 
 func applyMaxBytesOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid max_bytes arguments: %v", args)
+		return newOptionArgumentError("Invalid max_bytes arguments: %v", args)
 	}
 
 	if max, err := strconv.Atoi(args[0]); err == nil && max >= 0 {
 		po.MaxBytes = max
 	} else {
-		return fmt.Errorf("Invalid max_bytes: %s", args[0])
+		return newOptionArgumentError("Invalid max_bytes: %s", args[0])
 	}
 
 	return nil
@@ -612,7 +608,7 @@ func applyBackgroundOption(po *ProcessingOptions, args []string) error {
 			po.Flatten = true
 			po.Background = c
 		} else {
-			return fmt.Errorf("Invalid background argument: %s", err)
+			return newOptionArgumentError("Invalid background argument: %s", err)
 		}
 
 	case 3:
@@ -621,23 +617,23 @@ func applyBackgroundOption(po *ProcessingOptions, args []string) error {
 		if r, err := strconv.ParseUint(args[0], 10, 8); err == nil && r <= 255 {
 			po.Background.R = uint8(r)
 		} else {
-			return fmt.Errorf("Invalid background red channel: %s", args[0])
+			return newOptionArgumentError("Invalid background red channel: %s", args[0])
 		}
 
 		if g, err := strconv.ParseUint(args[1], 10, 8); err == nil && g <= 255 {
 			po.Background.G = uint8(g)
 		} else {
-			return fmt.Errorf("Invalid background green channel: %s", args[1])
+			return newOptionArgumentError("Invalid background green channel: %s", args[1])
 		}
 
 		if b, err := strconv.ParseUint(args[2], 10, 8); err == nil && b <= 255 {
 			po.Background.B = uint8(b)
 		} else {
-			return fmt.Errorf("Invalid background blue channel: %s", args[2])
+			return newOptionArgumentError("Invalid background blue channel: %s", args[2])
 		}
 
 	default:
-		return fmt.Errorf("Invalid background arguments: %v", args)
+		return newOptionArgumentError("Invalid background arguments: %v", args)
 	}
 
 	return nil
@@ -645,13 +641,13 @@ func applyBackgroundOption(po *ProcessingOptions, args []string) error {
 
 func applyBlurOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid blur arguments: %v", args)
+		return newOptionArgumentError("Invalid blur arguments: %v", args)
 	}
 
 	if b, err := strconv.ParseFloat(args[0], 32); err == nil && b >= 0 {
 		po.Blur = float32(b)
 	} else {
-		return fmt.Errorf("Invalid blur: %s", args[0])
+		return newOptionArgumentError("Invalid blur: %s", args[0])
 	}
 
 	return nil
@@ -659,13 +655,13 @@ func applyBlurOption(po *ProcessingOptions, args []string) error {
 
 func applySharpenOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid sharpen arguments: %v", args)
+		return newOptionArgumentError("Invalid sharpen arguments: %v", args)
 	}
 
 	if s, err := strconv.ParseFloat(args[0], 32); err == nil && s >= 0 {
 		po.Sharpen = float32(s)
 	} else {
-		return fmt.Errorf("Invalid sharpen: %s", args[0])
+		return newOptionArgumentError("Invalid sharpen: %s", args[0])
 	}
 
 	return nil
@@ -673,13 +669,13 @@ func applySharpenOption(po *ProcessingOptions, args []string) error {
 
 func applyPixelateOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid pixelate arguments: %v", args)
+		return newOptionArgumentError("Invalid pixelate arguments: %v", args)
 	}
 
 	if p, err := strconv.Atoi(args[0]); err == nil && p >= 0 {
 		po.Pixelate = p
 	} else {
-		return fmt.Errorf("Invalid pixelate: %s", args[0])
+		return newOptionArgumentError("Invalid pixelate: %s", args[0])
 	}
 
 	return nil
@@ -699,7 +695,7 @@ func applyPresetOption(po *ProcessingOptions, args []string, usedPresets ...stri
 				return err
 			}
 		} else {
-			return fmt.Errorf("Unknown preset: %s", preset)
+			return newOptionArgumentError("Unknown preset: %s", preset)
 		}
 	}
 
@@ -708,21 +704,21 @@ func applyPresetOption(po *ProcessingOptions, args []string, usedPresets ...stri
 
 func applyWatermarkOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 7 {
-		return fmt.Errorf("Invalid watermark arguments: %v", args)
+		return newOptionArgumentError("Invalid watermark arguments: %v", args)
 	}
 
 	if o, err := strconv.ParseFloat(args[0], 64); err == nil && o >= 0 && o <= 1 {
 		po.Watermark.Enabled = o > 0
 		po.Watermark.Opacity = o
 	} else {
-		return fmt.Errorf("Invalid watermark opacity: %s", args[0])
+		return newOptionArgumentError("Invalid watermark opacity: %s", args[0])
 	}
 
 	if len(args) > 1 && len(args[1]) > 0 {
 		if g, ok := gravityTypes[args[1]]; ok && slices.Contains(watermarkGravityTypes, g) {
 			po.Watermark.Position.Type = g
 		} else {
-			return fmt.Errorf("Invalid watermark position: %s", args[1])
+			return newOptionArgumentError("Invalid watermark position: %s", args[1])
 		}
 	}
 
@@ -730,7 +726,7 @@ func applyWatermarkOption(po *ProcessingOptions, args []string) error {
 		if x, err := strconv.ParseFloat(args[2], 64); err == nil {
 			po.Watermark.Position.X = x
 		} else {
-			return fmt.Errorf("Invalid watermark X offset: %s", args[2])
+			return newOptionArgumentError("Invalid watermark X offset: %s", args[2])
 		}
 	}
 
@@ -738,7 +734,7 @@ func applyWatermarkOption(po *ProcessingOptions, args []string) error {
 		if y, err := strconv.ParseFloat(args[3], 64); err == nil {
 			po.Watermark.Position.Y = y
 		} else {
-			return fmt.Errorf("Invalid watermark Y offset: %s", args[3])
+			return newOptionArgumentError("Invalid watermark Y offset: %s", args[3])
 		}
 	}
 
@@ -746,7 +742,7 @@ func applyWatermarkOption(po *ProcessingOptions, args []string) error {
 		if s, err := strconv.ParseFloat(args[4], 64); err == nil && s >= 0 {
 			po.Watermark.Scale = s
 		} else {
-			return fmt.Errorf("Invalid watermark scale: %s", args[4])
+			return newOptionArgumentError("Invalid watermark scale: %s", args[4])
 		}
 	}
 
@@ -755,13 +751,13 @@ func applyWatermarkOption(po *ProcessingOptions, args []string) error {
 
 func applyFormatOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid format arguments: %v", args)
+		return newOptionArgumentError("Invalid format arguments: %v", args)
 	}
 
 	if f, ok := imagetype.Types[args[0]]; ok {
 		po.Format = f
 	} else {
-		return fmt.Errorf("Invalid image format: %s", args[0])
+		return newOptionArgumentError("Invalid image format: %s", args[0])
 	}
 
 	return nil
@@ -769,7 +765,7 @@ func applyFormatOption(po *ProcessingOptions, args []string) error {
 
 func applyCacheBusterOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid cache buster arguments: %v", args)
+		return newOptionArgumentError("Invalid cache buster arguments: %v", args)
 	}
 
 	po.CacheBuster = args[0]
@@ -782,7 +778,7 @@ func applySkipProcessingFormatsOption(po *ProcessingOptions, args []string) erro
 		if f, ok := imagetype.Types[format]; ok {
 			po.SkipProcessingFormats = append(po.SkipProcessingFormats, f)
 		} else {
-			return fmt.Errorf("Invalid image format in skip processing: %s", format)
+			return newOptionArgumentError("Invalid image format in skip processing: %s", format)
 		}
 	}
 
@@ -791,7 +787,7 @@ func applySkipProcessingFormatsOption(po *ProcessingOptions, args []string) erro
 
 func applyRawOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid return_attachment arguments: %v", args)
+		return newOptionArgumentError("Invalid return_attachment arguments: %v", args)
 	}
 
 	po.Raw = parseBoolOption(args[0])
@@ -801,7 +797,7 @@ func applyRawOption(po *ProcessingOptions, args []string) error {
 
 func applyFilenameOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 2 {
-		return fmt.Errorf("Invalid filename arguments: %v", args)
+		return newOptionArgumentError("Invalid filename arguments: %v", args)
 	}
 
 	po.Filename = args[0]
@@ -809,7 +805,7 @@ func applyFilenameOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 && parseBoolOption(args[1]) {
 		decoded, err := base64.RawURLEncoding.DecodeString(po.Filename)
 		if err != nil {
-			return fmt.Errorf("Invalid filename encoding: %s", err)
+			return newOptionArgumentError("Invalid filename encoding: %s", err)
 		}
 
 		po.Filename = string(decoded)
@@ -820,16 +816,16 @@ func applyFilenameOption(po *ProcessingOptions, args []string) error {
 
 func applyExpiresOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid expires arguments: %v", args)
+		return newOptionArgumentError("Invalid expires arguments: %v", args)
 	}
 
 	timestamp, err := strconv.ParseInt(args[0], 10, 64)
 	if err != nil {
-		return fmt.Errorf("Invalid expires argument: %v", args[0])
+		return newOptionArgumentError("Invalid expires argument: %v", args[0])
 	}
 
 	if timestamp > 0 && timestamp < time.Now().Unix() {
-		return errExpiredURL
+		return newOptionArgumentError("Expired URL")
 	}
 
 	expires := time.Unix(timestamp, 0)
@@ -840,7 +836,7 @@ func applyExpiresOption(po *ProcessingOptions, args []string) error {
 
 func applyStripMetadataOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid strip metadata arguments: %v", args)
+		return newOptionArgumentError("Invalid strip metadata arguments: %v", args)
 	}
 
 	po.StripMetadata = parseBoolOption(args[0])
@@ -850,7 +846,7 @@ func applyStripMetadataOption(po *ProcessingOptions, args []string) error {
 
 func applyKeepCopyrightOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid keep copyright arguments: %v", args)
+		return newOptionArgumentError("Invalid keep copyright arguments: %v", args)
 	}
 
 	po.KeepCopyright = parseBoolOption(args[0])
@@ -860,7 +856,7 @@ func applyKeepCopyrightOption(po *ProcessingOptions, args []string) error {
 
 func applyStripColorProfileOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid strip color profile arguments: %v", args)
+		return newOptionArgumentError("Invalid strip color profile arguments: %v", args)
 	}
 
 	po.StripColorProfile = parseBoolOption(args[0])
@@ -870,7 +866,7 @@ func applyStripColorProfileOption(po *ProcessingOptions, args []string) error {
 
 func applyAutoRotateOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid auto rotate arguments: %v", args)
+		return newOptionArgumentError("Invalid auto rotate arguments: %v", args)
 	}
 
 	po.AutoRotate = parseBoolOption(args[0])
@@ -880,7 +876,7 @@ func applyAutoRotateOption(po *ProcessingOptions, args []string) error {
 
 func applyEnforceThumbnailOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid enforce thumbnail arguments: %v", args)
+		return newOptionArgumentError("Invalid enforce thumbnail arguments: %v", args)
 	}
 
 	po.EnforceThumbnail = parseBoolOption(args[0])
@@ -890,7 +886,7 @@ func applyEnforceThumbnailOption(po *ProcessingOptions, args []string) error {
 
 func applyReturnAttachmentOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid return_attachment arguments: %v", args)
+		return newOptionArgumentError("Invalid return_attachment arguments: %v", args)
 	}
 
 	po.ReturnAttachment = parseBoolOption(args[0])
@@ -904,13 +900,13 @@ func applyMaxSrcResolutionOption(po *ProcessingOptions, args []string) error {
 	}
 
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid max_src_resolution arguments: %v", args)
+		return newOptionArgumentError("Invalid max_src_resolution arguments: %v", args)
 	}
 
 	if x, err := strconv.ParseFloat(args[0], 64); err == nil && x > 0 {
 		po.SecurityOptions.MaxSrcResolution = int(x * 1000000)
 	} else {
-		return fmt.Errorf("Invalid max_src_resolution: %s", args[0])
+		return newOptionArgumentError("Invalid max_src_resolution: %s", args[0])
 	}
 
 	return nil
@@ -922,13 +918,13 @@ func applyMaxSrcFileSizeOption(po *ProcessingOptions, args []string) error {
 	}
 
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid max_src_file_size arguments: %v", args)
+		return newOptionArgumentError("Invalid max_src_file_size arguments: %v", args)
 	}
 
 	if x, err := strconv.Atoi(args[0]); err == nil {
 		po.SecurityOptions.MaxSrcFileSize = x
 	} else {
-		return fmt.Errorf("Invalid max_src_file_size: %s", args[0])
+		return newOptionArgumentError("Invalid max_src_file_size: %s", args[0])
 	}
 
 	return nil
@@ -940,13 +936,13 @@ func applyMaxAnimationFramesOption(po *ProcessingOptions, args []string) error {
 	}
 
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid max_animation_frames arguments: %v", args)
+		return newOptionArgumentError("Invalid max_animation_frames arguments: %v", args)
 	}
 
 	if x, err := strconv.Atoi(args[0]); err == nil && x > 0 {
 		po.SecurityOptions.MaxAnimationFrames = x
 	} else {
-		return fmt.Errorf("Invalid max_animation_frames: %s", args[0])
+		return newOptionArgumentError("Invalid max_animation_frames: %s", args[0])
 	}
 
 	return nil
@@ -958,13 +954,13 @@ func applyMaxAnimationFrameResolutionOption(po *ProcessingOptions, args []string
 	}
 
 	if len(args) > 1 {
-		return fmt.Errorf("Invalid max_animation_frame_resolution arguments: %v", args)
+		return newOptionArgumentError("Invalid max_animation_frame_resolution arguments: %v", args)
 	}
 
 	if x, err := strconv.ParseFloat(args[0], 64); err == nil {
 		po.SecurityOptions.MaxAnimationFrameResolution = int(x * 1000000)
 	} else {
-		return fmt.Errorf("Invalid max_animation_frame_resolution: %s", args[0])
+		return newOptionArgumentError("Invalid max_animation_frame_resolution: %s", args[0])
 	}
 
 	return nil
@@ -1062,7 +1058,7 @@ func applyURLOption(po *ProcessingOptions, name string, args []string, usedPrese
 		return applyMaxAnimationFrameResolutionOption(po, args)
 	}
 
-	return fmt.Errorf("Unknown processing option: %s", name)
+	return newUnknownOptionError("processing", name)
 }
 
 func applyURLOptions(po *ProcessingOptions, options urlOptions, usedPresets ...string) error {
@@ -1128,11 +1124,7 @@ func defaultProcessingOptions(headers http.Header) (*ProcessingOptions, error) {
 
 func parsePathOptions(parts []string, headers http.Header) (*ProcessingOptions, string, error) {
 	if _, ok := resizeTypes[parts[0]]; ok {
-		return nil, "", ierrors.New(
-			404,
-			"It looks like you're using the deprecated basic URL format",
-			"Invalid URL",
-		)
+		return nil, "", newInvalidURLError("It looks like you're using the deprecated basic URL format")
 	}
 
 	po, err := defaultProcessingOptions(headers)
@@ -1189,7 +1181,7 @@ func parsePathPresets(parts []string, headers http.Header) (*ProcessingOptions,
 
 func ParsePath(path string, headers http.Header) (*ProcessingOptions, string, error) {
 	if path == "" || path == "/" {
-		return nil, "", ierrors.New(404, fmt.Sprintf("Invalid path: %s", path), "Invalid URL")
+		return nil, "", newInvalidURLError("Invalid path: %s", path)
 	}
 
 	parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
@@ -1207,7 +1199,7 @@ func ParsePath(path string, headers http.Header) (*ProcessingOptions, string, er
 	}
 
 	if err != nil {
-		return nil, "", ierrors.New(404, err.Error(), "Invalid URL")
+		return nil, "", ierrors.Wrap(err, 0)
 	}
 
 	return po, imageURL, nil

+ 1 - 2
options/processing_options_test.go

@@ -601,8 +601,7 @@ func (s *ProcessingOptionsTestSuite) TestParseExpiresExpired() {
 	path := "/exp:1609448400/plain/http://images.dev/lorem/ipsum.jpg"
 	_, _, err := ParsePath(path, make(http.Header))
 
-	s.Require().Error(err)
-	s.Require().Equal(errExpiredURL.Error(), err.Error())
+	s.Require().Error(err, "Expired URL")
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathOnlyPresets() {

+ 31 - 25
processing_handler.go

@@ -2,6 +2,7 @@ package main
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"net/http"
 	"slices"
@@ -147,8 +148,7 @@ func respondWithImage(reqID string, r *http.Request, rw http.ResponseWriter, sta
 
 	var ierr *ierrors.Error
 	if err != nil {
-		ierr = ierrors.New(statusCode, fmt.Sprintf("Failed to write response: %s", err), "Failed to write response")
-		ierr.Unexpected = true
+		ierr = newResponseWriteError(err)
 
 		if config.ReportIOErrors {
 			sendErr(r.Context(), "IO", ierr)
@@ -183,7 +183,7 @@ func sendErr(ctx context.Context, errType string, err error) {
 	send := true
 
 	if ierr, ok := err.(*ierrors.Error); ok {
-		switch ierr.StatusCode {
+		switch ierr.StatusCode() {
 		case http.StatusServiceUnavailable:
 			errType = "timeout"
 		case 499:
@@ -231,15 +231,15 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 		signature = path[:signatureEnd]
 		path = path[signatureEnd:]
 	} else {
-		sendErrAndPanic(ctx, "path_parsing", ierrors.New(
-			404, fmt.Sprintf("Invalid path: %s", path), "Invalid URL",
-		))
+		sendErrAndPanic(ctx, "path_parsing", newInvalidURLErrorf(
+			http.StatusNotFound, "Invalid path: %s", path),
+		)
 	}
 
 	path = fixPath(path)
 
 	if err := security.VerifySignature(signature, path); err != nil {
-		sendErrAndPanic(ctx, "security", ierrors.New(403, err.Error(), "Forbidden"))
+		sendErrAndPanic(ctx, "security", err)
 	}
 
 	po, imageURL, err := options.ParsePath(path, r.Header)
@@ -261,10 +261,9 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 
 	// SVG is a special case. Though saving to svg is not supported, SVG->SVG is.
 	if !vips.SupportsSave(po.Format) && po.Format != imagetype.Unknown && po.Format != imagetype.SVG {
-		sendErrAndPanic(ctx, "path_parsing", ierrors.New(
-			422,
-			fmt.Sprintf("Resulting image format is not supported: %s", po.Format),
-			"Invalid URL",
+		sendErrAndPanic(ctx, "path_parsing", newInvalidURLErrorf(
+			http.StatusUnprocessableEntity,
+			"Resulting image format is not supported: %s", po.Format,
 		))
 	}
 
@@ -291,7 +290,7 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 	if queueSem != nil {
 		acquired := queueSem.TryAcquire(1)
 		if !acquired {
-			panic(ierrors.New(429, "Too many requests", "Too many requests"))
+			panic(newTooManyRequestsError())
 		}
 		defer queueSem.Release(1)
 	}
@@ -334,21 +333,28 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 		return imagedata.Download(ctx, imageURL, "source image", downloadOpts, po.SecurityOptions)
 	}()
 
-	if err == nil {
+	var nmErr imagedata.NotModifiedError
+
+	switch {
+	case err == nil:
 		defer originData.Close()
-	} else if nmErr, ok := err.(*imagedata.ErrorNotModified); ok {
+
+	case errors.As(err, &nmErr):
 		if config.ETagEnabled && len(etagHandler.ImageEtagExpected()) != 0 {
 			rw.Header().Set("ETag", etagHandler.GenerateExpectedETag())
 		}
-		respondWithNotModified(reqID, r, rw, po, imageURL, nmErr.Headers)
+		respondWithNotModified(reqID, r, rw, po, imageURL, nmErr.Headers())
 		return
-	} else {
+
+	default:
 		// This may be a request timeout error or a request cancelled error.
 		// Check it before moving further
 		checkErr(ctx, "timeout", router.CheckTimeout(ctx))
 
 		ierr := ierrors.Wrap(err, 0)
-		ierr.Unexpected = ierr.Unexpected || config.ReportDownloadingErrors
+		if config.ReportDownloadingErrors {
+			ierr = ierrors.Wrap(ierr, 0, ierrors.WithShouldReport(true))
+		}
 
 		sendErr(ctx, "download", ierr)
 
@@ -358,7 +364,7 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 
 		// We didn't panic, so the error is not reported.
 		// Report it now
-		if ierr.Unexpected {
+		if ierr.ShouldReport() {
 			errorreport.Report(ierr, r)
 		}
 
@@ -367,7 +373,7 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 		if config.FallbackImageHTTPCode > 0 {
 			statusCode = config.FallbackImageHTTPCode
 		} else {
-			statusCode = ierr.StatusCode
+			statusCode = ierr.StatusCode()
 		}
 
 		originData = imagedata.FallbackImage
@@ -413,17 +419,17 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 	}
 
 	if !vips.SupportsLoad(originData.Type) {
-		sendErrAndPanic(ctx, "processing", ierrors.New(
-			422,
-			fmt.Sprintf("Source image format is not supported: %s", originData.Type),
-			"Invalid URL",
+		sendErrAndPanic(ctx, "processing", newInvalidURLErrorf(
+			http.StatusUnprocessableEntity,
+			"Source image format is not supported: %s", originData.Type,
 		))
 	}
 
 	// At this point we can't allow requested format to be SVG as we can't save SVGs
 	if po.Format == imagetype.SVG {
-		sendErrAndPanic(ctx, "processing", ierrors.New(
-			422, "Resulting image format is not supported: svg", "Invalid URL",
+		sendErrAndPanic(ctx, "processing", newInvalidURLErrorf(
+			http.StatusUnprocessableEntity,
+			"Resulting image format is not supported: svg",
 		))
 	}
 

+ 0 - 1
processing_handler_test.go

@@ -225,7 +225,6 @@ func (s *ProcessingHandlerTestSuite) TestSourceNetworkValidation() {
 	var rw *httptest.ResponseRecorder
 
 	u := fmt.Sprintf("/unsafe/rs:fill:4:4/plain/%s/test1.png", server.URL)
-	fmt.Println(u)
 
 	rw = s.send(u)
 	s.Require().Equal(200, rw.Result().StatusCode)

+ 51 - 0
router/errors.go

@@ -0,0 +1,51 @@
+package router
+
+import (
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/imgproxy/imgproxy/v3/ierrors"
+)
+
+type (
+	RouteNotDefinedError  string
+	RequestCancelledError string
+	RequestTimeoutError   string
+)
+
+func newRouteNotDefinedError(path string) *ierrors.Error {
+	return ierrors.Wrap(
+		RouteNotDefinedError(fmt.Sprintf("Route for %s is not defined", path)),
+		1,
+		ierrors.WithStatusCode(http.StatusNotFound),
+		ierrors.WithPublicMessage("Not found"),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e RouteNotDefinedError) Error() string { return string(e) }
+
+func newRequestCancelledError(after time.Duration) *ierrors.Error {
+	return ierrors.Wrap(
+		RequestCancelledError(fmt.Sprintf("Request was cancelled after %v", after)),
+		1,
+		ierrors.WithStatusCode(499),
+		ierrors.WithPublicMessage("Cancelled"),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e RequestCancelledError) Error() string { return string(e) }
+
+func newRequestTimeoutError(after time.Duration) *ierrors.Error {
+	return ierrors.Wrap(
+		RequestTimeoutError(fmt.Sprintf("Request was timed out after %v", after)),
+		1,
+		ierrors.WithStatusCode(http.StatusServiceUnavailable),
+		ierrors.WithPublicMessage("Gateway Timeout"),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e RequestTimeoutError) Error() string { return string(e) }

+ 5 - 3
router/logging.go

@@ -24,7 +24,7 @@ func LogResponse(reqID string, r *http.Request, status int, err *ierrors.Error,
 	var level log.Level
 
 	switch {
-	case status >= 500 || (err != nil && err.Unexpected):
+	case status >= 500 || (err != nil && err.StatusCode() >= 500):
 		level = log.ErrorLevel
 	case status >= 400:
 		level = log.WarnLevel
@@ -44,8 +44,10 @@ func LogResponse(reqID string, r *http.Request, status int, err *ierrors.Error,
 	if err != nil {
 		fields["error"] = err
 
-		if stack := err.FormatStack(); len(stack) > 0 {
-			fields["stack"] = stack
+		if level <= log.ErrorLevel {
+			if stack := err.FormatStack(); len(stack) > 0 {
+				fields["stack"] = stack
+			}
 		}
 	}
 

+ 1 - 3
router/router.go

@@ -2,7 +2,6 @@ package router
 
 import (
 	"encoding/json"
-	"fmt"
 	"net"
 	"net/http"
 	"regexp"
@@ -11,7 +10,6 @@ import (
 	nanoid "github.com/matoous/go-nanoid/v2"
 
 	"github.com/imgproxy/imgproxy/v3/config"
-	"github.com/imgproxy/imgproxy/v3/ierrors"
 )
 
 const (
@@ -159,7 +157,7 @@ func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
 		}
 	}
 
-	LogResponse(reqID, req, 404, ierrors.New(404, fmt.Sprintf("Route for %s is not defined", req.URL.Path), "Not found"))
+	LogResponse(reqID, req, 404, newRouteNotDefinedError(req.URL.Path))
 
 	rw.Header().Set("Content-Type", "text/plain")
 	rw.WriteHeader(404)

+ 3 - 4
router/timer.go

@@ -2,7 +2,6 @@ package router
 
 import (
 	"context"
-	"fmt"
 	"net/http"
 	"time"
 
@@ -34,11 +33,11 @@ func CheckTimeout(ctx context.Context) error {
 		err := ctx.Err()
 		switch err {
 		case context.Canceled:
-			return ierrors.New(499, fmt.Sprintf("Request was cancelled after %v", d), "Cancelled")
+			return newRequestCancelledError(d)
 		case context.DeadlineExceeded:
-			return ierrors.New(http.StatusServiceUnavailable, fmt.Sprintf("Request was timed out after %v", d), "Timeout")
+			return newRequestTimeoutError(d)
 		default:
-			return err
+			return ierrors.Wrap(err, 0)
 		}
 	default:
 		return nil

+ 89 - 0
security/errors.go

@@ -0,0 +1,89 @@
+package security
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/imgproxy/imgproxy/v3/ierrors"
+)
+
+type (
+	SignatureError       string
+	FileSizeError        struct{}
+	ImageResolutionError string
+	SecurityOptionsError struct{}
+	SourceURLError       string
+	SourceAddressError   string
+)
+
+func newSignatureError(msg string) error {
+	return ierrors.Wrap(
+		SignatureError(msg),
+		1,
+		ierrors.WithStatusCode(http.StatusForbidden),
+		ierrors.WithPublicMessage("Forbidden"),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e SignatureError) Error() string { return string(e) }
+
+func newFileSizeError() error {
+	return ierrors.Wrap(
+		FileSizeError{},
+		1,
+		ierrors.WithStatusCode(http.StatusUnprocessableEntity),
+		ierrors.WithPublicMessage("Invalid source image"),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e FileSizeError) Error() string { return "Source image file is too big" }
+
+func newImageResolutionError(msg string) error {
+	return ierrors.Wrap(
+		ImageResolutionError(msg),
+		1,
+		ierrors.WithStatusCode(http.StatusUnprocessableEntity),
+		ierrors.WithPublicMessage("Invalid source image"),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e ImageResolutionError) Error() string { return string(e) }
+
+func newSecurityOptionsError() error {
+	return ierrors.Wrap(
+		SecurityOptionsError{},
+		1,
+		ierrors.WithStatusCode(http.StatusForbidden),
+		ierrors.WithPublicMessage("Invalid URL"),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e SecurityOptionsError) Error() string { return "Security processing options are not allowed" }
+
+func newSourceURLError(imageURL string) error {
+	return ierrors.Wrap(
+		SourceURLError(fmt.Sprintf("Source URL is not allowed: %s", imageURL)),
+		1,
+		ierrors.WithStatusCode(http.StatusNotFound),
+		ierrors.WithPublicMessage("Invalid source URL"),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e SourceURLError) Error() string { return string(e) }
+
+func newSourceAddressError(msg string) error {
+	return ierrors.Wrap(
+		SourceAddressError(msg),
+		1,
+		ierrors.WithStatusCode(http.StatusNotFound),
+		ierrors.WithPublicMessage("Invalid source URL"),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e SourceAddressError) Error() string { return string(e) }

+ 2 - 6
security/file_size.go

@@ -2,12 +2,8 @@ package security
 
 import (
 	"io"
-
-	"github.com/imgproxy/imgproxy/v3/ierrors"
 )
 
-var ErrSourceFileTooBig = ierrors.New(422, "Source image file is too big", "Invalid source image")
-
 type hardLimitReader struct {
 	r    io.Reader
 	left int
@@ -15,7 +11,7 @@ type hardLimitReader struct {
 
 func (lr *hardLimitReader) Read(p []byte) (n int, err error) {
 	if lr.left <= 0 {
-		return 0, ErrSourceFileTooBig
+		return 0, newFileSizeError()
 	}
 	if len(p) > lr.left {
 		p = p[0:lr.left]
@@ -27,7 +23,7 @@ func (lr *hardLimitReader) Read(p []byte) (n int, err error) {
 
 func CheckFileSize(size int, opts Options) error {
 	if opts.MaxSrcFileSize > 0 && size > opts.MaxSrcFileSize {
-		return ErrSourceFileTooBig
+		return newFileSizeError()
 	}
 
 	return nil

+ 2 - 6
security/image_size.go

@@ -1,23 +1,19 @@
 package security
 
 import (
-	"github.com/imgproxy/imgproxy/v3/ierrors"
 	"github.com/imgproxy/imgproxy/v3/imath"
 )
 
-var ErrSourceResolutionTooBig = ierrors.New(422, "Source image resolution is too big", "Invalid source image")
-var ErrSourceFrameResolutionTooBig = ierrors.New(422, "Source image frame resolution is too big", "Invalid source image")
-
 func CheckDimensions(width, height, frames int, opts Options) error {
 	frames = imath.Max(frames, 1)
 
 	if frames > 1 && opts.MaxAnimationFrameResolution > 0 {
 		if width*height > opts.MaxAnimationFrameResolution {
-			return ErrSourceFrameResolutionTooBig
+			return newImageResolutionError("Source image frame resolution is too big")
 		}
 	} else {
 		if width*height*frames > opts.MaxSrcResolution {
-			return ErrSourceResolutionTooBig
+			return newImageResolutionError("Source image resolution is too big")
 		}
 	}
 

+ 1 - 4
security/options.go

@@ -2,11 +2,8 @@ package security
 
 import (
 	"github.com/imgproxy/imgproxy/v3/config"
-	"github.com/imgproxy/imgproxy/v3/ierrors"
 )
 
-var ErrSecurityOptionsNotAllowed = ierrors.New(403, "Security processing options are not allowed", "Invalid URL")
-
 type Options struct {
 	MaxSrcResolution            int
 	MaxSrcFileSize              int
@@ -28,5 +25,5 @@ func IsSecurityOptionsAllowed() error {
 		return nil
 	}
 
-	return ErrSecurityOptionsNotAllowed
+	return newSecurityOptionsError()
 }

+ 2 - 8
security/signature.go

@@ -4,16 +4,10 @@ import (
 	"crypto/hmac"
 	"crypto/sha256"
 	"encoding/base64"
-	"errors"
 
 	"github.com/imgproxy/imgproxy/v3/config"
 )
 
-var (
-	ErrInvalidSignature         = errors.New("Invalid signature")
-	ErrInvalidSignatureEncoding = errors.New("Invalid signature encoding")
-)
-
 func VerifySignature(signature, path string) error {
 	if len(config.Keys) == 0 || len(config.Salts) == 0 {
 		return nil
@@ -27,7 +21,7 @@ func VerifySignature(signature, path string) error {
 
 	messageMAC, err := base64.RawURLEncoding.DecodeString(signature)
 	if err != nil {
-		return ErrInvalidSignatureEncoding
+		return newSignatureError("Invalid signature encoding")
 	}
 
 	for i := 0; i < len(config.Keys); i++ {
@@ -36,7 +30,7 @@ func VerifySignature(signature, path string) error {
 		}
 	}
 
-	return ErrInvalidSignature
+	return newSignatureError("Invalid signature")
 }
 
 func signatureFor(str string, key, salt []byte, signatureSize int) []byte {

+ 5 - 14
security/source.go

@@ -1,17 +1,12 @@
 package security
 
 import (
-	"errors"
 	"fmt"
 	"net"
 
 	"github.com/imgproxy/imgproxy/v3/config"
-	"github.com/imgproxy/imgproxy/v3/ierrors"
 )
 
-var ErrSourceAddressNotAllowed = errors.New("source address is not allowed")
-var ErrInvalidSourceAddress = errors.New("invalid source address")
-
 func VerifySourceURL(imageURL string) error {
 	if len(config.AllowedSources) == 0 {
 		return nil
@@ -23,11 +18,7 @@ func VerifySourceURL(imageURL string) error {
 		}
 	}
 
-	return ierrors.New(
-		404,
-		fmt.Sprintf("Source URL is not allowed: %s", imageURL),
-		"Invalid source",
-	)
+	return newSourceURLError(imageURL)
 }
 
 func VerifySourceNetwork(addr string) error {
@@ -38,19 +29,19 @@ func VerifySourceNetwork(addr string) error {
 
 	ip := net.ParseIP(host)
 	if ip == nil {
-		return ErrInvalidSourceAddress
+		return newSourceAddressError(fmt.Sprintf("Invalid source address: %s", addr))
 	}
 
 	if !config.AllowLoopbackSourceAddresses && (ip.IsLoopback() || ip.IsUnspecified()) {
-		return ErrSourceAddressNotAllowed
+		return newSourceAddressError(fmt.Sprintf("Loopback source address is not allowed: %s", addr))
 	}
 
 	if !config.AllowLinkLocalSourceAddresses && (ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()) {
-		return ErrSourceAddressNotAllowed
+		return newSourceAddressError(fmt.Sprintf("Link-local source address is not allowed: %s", addr))
 	}
 
 	if !config.AllowPrivateSourceAddresses && ip.IsPrivate() {
-		return ErrSourceAddressNotAllowed
+		return newSourceAddressError(fmt.Sprintf("Private source address is not allowed: %s", addr))
 	}
 
 	return nil

+ 12 - 13
security/source_test.go

@@ -14,7 +14,7 @@ func TestVerifySourceNetwork(t *testing.T) {
 		allowLoopback  bool
 		allowLinkLocal bool
 		allowPrivate   bool
-		expectedErr    error
+		expectErr      bool
 	}{
 		{
 			name:           "Invalid IP address",
@@ -22,7 +22,7 @@ func TestVerifySourceNetwork(t *testing.T) {
 			allowLoopback:  true,
 			allowLinkLocal: true,
 			allowPrivate:   true,
-			expectedErr:    ErrInvalidSourceAddress,
+			expectErr:      true,
 		},
 		{
 			name:           "Loopback local not allowed",
@@ -30,7 +30,7 @@ func TestVerifySourceNetwork(t *testing.T) {
 			allowLoopback:  false,
 			allowLinkLocal: true,
 			allowPrivate:   true,
-			expectedErr:    ErrSourceAddressNotAllowed,
+			expectErr:      true,
 		},
 		{
 			name:           "Loopback local allowed",
@@ -38,7 +38,7 @@ func TestVerifySourceNetwork(t *testing.T) {
 			allowLoopback:  true,
 			allowLinkLocal: true,
 			allowPrivate:   true,
-			expectedErr:    nil,
+			expectErr:      false,
 		},
 		{
 			name:           "Unspecified (0.0.0.0) not allowed",
@@ -46,7 +46,7 @@ func TestVerifySourceNetwork(t *testing.T) {
 			allowLoopback:  false,
 			allowLinkLocal: true,
 			allowPrivate:   true,
-			expectedErr:    ErrSourceAddressNotAllowed,
+			expectErr:      true,
 		},
 		{
 			name:           "Link local unicast not allowed",
@@ -54,7 +54,7 @@ func TestVerifySourceNetwork(t *testing.T) {
 			allowLoopback:  true,
 			allowLinkLocal: false,
 			allowPrivate:   true,
-			expectedErr:    ErrSourceAddressNotAllowed,
+			expectErr:      true,
 		},
 		{
 			name:           "Link local unicast allowed",
@@ -62,7 +62,7 @@ func TestVerifySourceNetwork(t *testing.T) {
 			allowLoopback:  true,
 			allowLinkLocal: true,
 			allowPrivate:   true,
-			expectedErr:    nil,
+			expectErr:      false,
 		},
 		{
 			name:           "Private address not allowed",
@@ -70,7 +70,7 @@ func TestVerifySourceNetwork(t *testing.T) {
 			allowLoopback:  true,
 			allowLinkLocal: true,
 			allowPrivate:   false,
-			expectedErr:    ErrSourceAddressNotAllowed,
+			expectErr:      true,
 		},
 		{
 			name:           "Private address allowed",
@@ -78,7 +78,7 @@ func TestVerifySourceNetwork(t *testing.T) {
 			allowLoopback:  true,
 			allowLinkLocal: true,
 			allowPrivate:   true,
-			expectedErr:    nil,
+			expectErr:      false,
 		},
 		{
 			name:           "Global unicast should be allowed",
@@ -86,7 +86,7 @@ func TestVerifySourceNetwork(t *testing.T) {
 			allowLoopback:  false,
 			allowLinkLocal: false,
 			allowPrivate:   false,
-			expectedErr:    nil,
+			expectErr:      false,
 		},
 		{
 			name:           "Port in address with global IP",
@@ -94,7 +94,7 @@ func TestVerifySourceNetwork(t *testing.T) {
 			allowLoopback:  false,
 			allowLinkLocal: false,
 			allowPrivate:   false,
-			expectedErr:    nil,
+			expectErr:      false,
 		},
 	}
 
@@ -119,9 +119,8 @@ func TestVerifySourceNetwork(t *testing.T) {
 
 			err := VerifySourceNetwork(tc.addr)
 
-			if tc.expectedErr != nil {
+			if tc.expectErr {
 				require.Error(t, err)
-				require.Equal(t, tc.expectedErr, err)
 			} else {
 				require.NoError(t, err)
 			}

+ 8 - 12
server.go

@@ -20,11 +20,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
-var (
-	imgproxyIsRunningMsg = []byte("imgproxy is running")
-
-	errInvalidSecret = ierrors.New(403, "Invalid secret", "Forbidden")
-)
+var imgproxyIsRunningMsg = []byte("imgproxy is running")
 
 func buildRouter() *router.Router {
 	r := router.New(config.PathPrefix)
@@ -125,7 +121,7 @@ func withSecret(h router.RouteHandler) router.RouteHandler {
 		if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), authHeader) == 1 {
 			h(reqID, rw, r)
 		} else {
-			panic(errInvalidSecret)
+			panic(newInvalidSecretError())
 		}
 	}
 }
@@ -148,21 +144,21 @@ func withPanicHandler(h router.RouteHandler) router.RouteHandler {
 					panic(rerr)
 				}
 
-				ierr := ierrors.Wrap(err, 2)
+				ierr := ierrors.Wrap(err, 0)
 
-				if ierr.Unexpected {
+				if ierr.ShouldReport() {
 					errorreport.Report(err, r)
 				}
 
-				router.LogResponse(reqID, r, ierr.StatusCode, ierr)
+				router.LogResponse(reqID, r, ierr.StatusCode(), ierr)
 
 				rw.Header().Set("Content-Type", "text/plain")
-				rw.WriteHeader(ierr.StatusCode)
+				rw.WriteHeader(ierr.StatusCode())
 
 				if config.DevelopmentErrorsMode {
-					rw.Write([]byte(ierr.Message))
+					rw.Write([]byte(ierr.Error()))
 				} else {
-					rw.Write([]byte(ierr.PublicMessage))
+					rw.Write([]byte(ierr.PublicMessage()))
 				}
 			}
 		}()

+ 13 - 0
vips/errors.go

@@ -0,0 +1,13 @@
+package vips
+
+import (
+	"github.com/imgproxy/imgproxy/v3/ierrors"
+)
+
+type VipsError string
+
+func newVipsError(msg string) error {
+	return ierrors.Wrap(VipsError(msg), 2)
+}
+
+func (e VipsError) Error() string { return string(e) }

+ 7 - 4
vips/vips.go

@@ -11,6 +11,7 @@ import (
 	"context"
 	"errors"
 	"math"
+	"net/http"
 	"os"
 	"regexp"
 	"runtime"
@@ -207,13 +208,15 @@ func Error() error {
 	defer C.vips_error_clear()
 
 	errstr := strings.TrimSpace(C.GoString(C.vips_error_buffer()))
-	err := ierrors.NewUnexpected(errstr, 1)
+	err := newVipsError(errstr)
 
 	for _, re := range badImageErrRe {
 		if re.MatchString(errstr) {
-			err.StatusCode = 422
-			err.PublicMessage = "Broken or unsupported image"
-			break
+			return ierrors.Wrap(
+				err, 0,
+				ierrors.WithStatusCode(http.StatusUnprocessableEntity),
+				ierrors.WithPublicMessage("Broken or unsupported image"),
+			)
 		}
 	}