Explorar o código

Server package

Viktor Sokolov hai 1 mes
pai
achega
d28eb761e3

+ 14 - 12
errors.go

@@ -14,6 +14,20 @@ type (
 	InvalidSecretError   struct{}
 )
 
+// Error categories which are used in metrics
+// NOTE: possibly -> metrics (?)
+const (
+	categoryTimeout       = "timeout"
+	categoryImageDataSize = "image_data_size"
+	categoryPathParsing   = "path_parsing"
+	categorySecurity      = "security"
+	categoryQueue         = "queue"
+	categoryDownload      = "download"
+	categoryProcessing    = "processing"
+	categoryIO            = "IO"
+	categoryStreaming     = "streaming"
+)
+
 func newResponseWriteError(cause error) *ierrors.Error {
 	return ierrors.Wrap(
 		ResponseWriteError{cause},
@@ -53,15 +67,3 @@ func newTooManyRequestsError() error {
 }
 
 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" }

+ 14 - 0
handlers/head.go

@@ -0,0 +1,14 @@
+package handlers
+
+import (
+	"net/http"
+
+	"github.com/imgproxy/imgproxy/v3/server"
+)
+
+// HeadHandler is a simple handler that responds with a 200 OK status
+func HeadHandler(reqID string, rw http.ResponseWriter, r *http.Request) error {
+	server.LogResponse(reqID, r, 200, nil)
+	rw.WriteHeader(200)
+	return nil
+}

+ 46 - 0
handlers/health.go

@@ -0,0 +1,46 @@
+package handlers
+
+import (
+	"net/http"
+
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
+	"github.com/imgproxy/imgproxy/v3/ierrors"
+	"github.com/imgproxy/imgproxy/v3/server"
+	"github.com/imgproxy/imgproxy/v3/vips"
+)
+
+var imgproxyIsRunningMsg = []byte("imgproxy is running")
+
+// HealthHandler handles the health check requests
+func HealthHandler(reqID string, rw http.ResponseWriter, r *http.Request) error {
+	var (
+		status int
+		msg    []byte
+		ierr   *ierrors.Error
+	)
+
+	if err := vips.Health(); err == nil {
+		status = http.StatusOK
+		msg = imgproxyIsRunningMsg
+	} else {
+		status = http.StatusInternalServerError
+		msg = []byte("Error")
+		ierr = ierrors.Wrap(err, 1)
+	}
+
+	if len(msg) == 0 {
+		msg = []byte{' '}
+	}
+
+	// Log response only if something went wrong
+	if ierr != nil {
+		server.LogResponse(reqID, r, status, ierr)
+	}
+
+	rw.Header().Set(httpheaders.ContentType, "text/plain")
+	rw.Header().Set(httpheaders.CacheControl, "no-cache")
+	rw.WriteHeader(status)
+	rw.Write(msg)
+
+	return nil
+}

+ 32 - 0
handlers/health_test.go

@@ -0,0 +1,32 @@
+package handlers
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
+)
+
+func TestHealthHandler(t *testing.T) {
+	// Create a ResponseRecorder to record the response
+	rr := httptest.NewRecorder()
+
+	// Call the handler function directly (no need for actual HTTP request)
+	HealthHandler("test-req-id", rr, nil)
+
+	// Check that we get a valid response (either 200 or 500 depending on vips state)
+	assert.True(t, rr.Code == http.StatusOK || rr.Code == http.StatusInternalServerError)
+
+	// Check headers are set correctly
+	assert.Equal(t, "text/plain", rr.Header().Get(httpheaders.ContentType))
+	assert.Equal(t, "no-cache", rr.Header().Get(httpheaders.CacheControl))
+
+	// Verify response format and content
+	body := rr.Body.String()
+	assert.NotEmpty(t, body)
+
+	assert.Equal(t, "imgproxy is running", body)
+}

+ 11 - 5
landing.go → handlers/landing.go

@@ -1,6 +1,10 @@
-package main
+package handlers
 
-import "net/http"
+import (
+	"net/http"
+
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
+)
 
 var landingTmpl = []byte(`
 <!doctype html>
@@ -39,8 +43,10 @@ var landingTmpl = []byte(`
 </html>
 `)
 
-func handleLanding(reqID string, rw http.ResponseWriter, r *http.Request) {
-	rw.Header().Set("Content-Type", "text/html")
-	rw.WriteHeader(200)
+// HandleLanding handles the landing page requests
+func HandleLanding(reqID string, rw http.ResponseWriter, r *http.Request) error {
+	rw.Header().Set(httpheaders.ContentType, "text/html")
+	rw.WriteHeader(http.StatusOK)
 	rw.Write(landingTmpl)
+	return nil
 }

+ 2 - 0
httpheaders/headers.go

@@ -65,5 +65,7 @@ const (
 	XOriginHeight                   = "X-Origin-Height"
 	XResultWidth                    = "X-Result-Width"
 	XResultHeight                   = "X-Result-Height"
+	XRequestID                      = "X-Request-ID"
+	XRealIP                         = "X-Real-IP"
 	XOriginContentLength            = "X-Origin-Content-Length"
 )

+ 19 - 0
ierrors/errors.go

@@ -7,6 +7,10 @@ import (
 	"strings"
 )
 
+const (
+	defaultCategory = "default"
+)
+
 type Option func(*Error)
 
 type Error struct {
@@ -16,6 +20,7 @@ type Error struct {
 	statusCode    int
 	publicMessage string
 	shouldReport  bool
+	category      string
 
 	stack []uintptr
 }
@@ -64,6 +69,14 @@ func (e *Error) Callers() []uintptr {
 	return e.stack
 }
 
+func (e *Error) Category() string {
+	if e.category == "" {
+		return defaultCategory
+	}
+
+	return e.category
+}
+
 func (e *Error) FormatStackLines() []string {
 	lines := make([]string, len(e.stack))
 
@@ -141,6 +154,12 @@ func WithShouldReport(report bool) Option {
 	}
 }
 
+func WithCategory(category string) Option {
+	return func(e *Error) {
+		e.category = category
+	}
+}
+
 func callers(skip int) []uintptr {
 	stack := make([]uintptr, 10)
 	n := runtime.Callers(skip+2, stack)

+ 30 - 10
main.go

@@ -16,6 +16,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/config/loadenv"
 	"github.com/imgproxy/imgproxy/v3/errorreport"
 	"github.com/imgproxy/imgproxy/v3/gliblog"
+	"github.com/imgproxy/imgproxy/v3/handlers"
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/logger"
 	"github.com/imgproxy/imgproxy/v3/memory"
@@ -23,10 +24,33 @@ import (
 	"github.com/imgproxy/imgproxy/v3/metrics/prometheus"
 	"github.com/imgproxy/imgproxy/v3/options"
 	"github.com/imgproxy/imgproxy/v3/processing"
+	"github.com/imgproxy/imgproxy/v3/server"
 	"github.com/imgproxy/imgproxy/v3/version"
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
+func buildRouter(r *server.Router) *server.Router {
+	r.GET("/", handlers.HandleLanding, true)
+	r.GET("", handlers.HandleLanding, true)
+
+	r.GET("/", r.WithMetrics(
+		r.WithCORS(
+			r.WithReportError(
+				r.WithPanic(
+					r.WithSecret(handleProcessing),
+				),
+			),
+		),
+	), false)
+
+	r.HEAD("/", r.WithCORS(handlers.HeadHandler), false)
+	r.OPTIONS("/", r.WithCORS(handlers.HeadHandler), false)
+
+	r.HealthHandler = handlers.HealthHandler
+
+	return r
+}
+
 func initialize() error {
 	if err := loadenv.Load(); err != nil {
 		return err
@@ -103,25 +127,21 @@ func run(ctx context.Context) error {
 		}
 	}()
 
-	ctx, cancel := context.WithCancel(ctx)
+	ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
 
 	if err := prometheus.StartServer(cancel); err != nil {
 		return err
 	}
 
-	s, err := startServer(cancel)
+	cfg := server.NewConfigFromEnv()
+	r := server.NewRouter(cfg)
+	s, err := server.Start(cancel, buildRouter(r), cfg)
 	if err != nil {
 		return err
 	}
-	defer shutdownServer(s)
+	defer s.Shutdown(ctx)
 
-	stop := make(chan os.Signal, 1)
-	signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
-
-	select {
-	case <-ctx.Done():
-	case <-stop:
-	}
+	<-ctx.Done()
 
 	return nil
 }

+ 1 - 1
metrics/prometheus/prometheus.go

@@ -161,7 +161,7 @@ func StartServer(cancel context.CancelFunc) error {
 
 	s := http.Server{Handler: promhttp.Handler()}
 
-	l, err := reuseport.Listen("tcp", config.PrometheusBind)
+	l, err := reuseport.Listen("tcp", config.PrometheusBind, config.SoReuseport)
 	if err != nil {
 		return fmt.Errorf("Can't start Prometheus metrics server: %s", err)
 	}

+ 2 - 2
processing/pipeline.go

@@ -6,7 +6,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/imagetype"
 	"github.com/imgproxy/imgproxy/v3/options"
-	"github.com/imgproxy/imgproxy/v3/router"
+	"github.com/imgproxy/imgproxy/v3/server"
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
@@ -89,7 +89,7 @@ func (p pipeline) Run(ctx context.Context, img *vips.Image, po *options.Processi
 			return err
 		}
 
-		if err := router.CheckTimeout(ctx); err != nil {
+		if err := server.CheckTimeout(ctx); err != nil {
 			return err
 		}
 	}

+ 3 - 3
processing/processing.go

@@ -12,8 +12,8 @@ import (
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/imagetype"
 	"github.com/imgproxy/imgproxy/v3/options"
-	"github.com/imgproxy/imgproxy/v3/router"
 	"github.com/imgproxy/imgproxy/v3/security"
+	"github.com/imgproxy/imgproxy/v3/server"
 	"github.com/imgproxy/imgproxy/v3/svg"
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
@@ -173,7 +173,7 @@ func transformAnimated(ctx context.Context, img *vips.Image, po *options.Process
 				return err
 			}
 
-			if err = router.CheckTimeout(ctx); err != nil {
+			if err = server.CheckTimeout(ctx); err != nil {
 				return err
 			}
 		}
@@ -240,7 +240,7 @@ func saveImageToFitBytes(ctx context.Context, po *options.ProcessingOptions, img
 		}
 		imgdata.Close()
 
-		if err := router.CheckTimeout(ctx); err != nil {
+		if err := server.CheckTimeout(ctx); err != nil {
 			return nil, err
 		}
 

+ 83 - 58
processing_handler.go

@@ -27,8 +27,8 @@ import (
 	"github.com/imgproxy/imgproxy/v3/metrics/stats"
 	"github.com/imgproxy/imgproxy/v3/options"
 	"github.com/imgproxy/imgproxy/v3/processing"
-	"github.com/imgproxy/imgproxy/v3/router"
 	"github.com/imgproxy/imgproxy/v3/security"
+	"github.com/imgproxy/imgproxy/v3/server"
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
@@ -122,17 +122,23 @@ func setCanonical(rw http.ResponseWriter, originURL string) {
 	}
 }
 
-func writeOriginContentLengthDebugHeader(ctx context.Context, rw http.ResponseWriter, originData imagedata.ImageData) {
+func writeOriginContentLengthDebugHeader(rw http.ResponseWriter, originData imagedata.ImageData) error {
 	if !config.EnableDebugHeaders {
-		return
+		return nil
 	}
 
 	size, err := originData.Size()
 	if err != nil {
-		checkErr(ctx, "image_data_size", err)
+		return ierrors.Wrap(
+			err, 0,
+			ierrors.WithCategory(categoryImageDataSize),
+			ierrors.WithShouldReport(true),
+		)
 	}
 
 	rw.Header().Set(httpheaders.XOriginContentLength, strconv.Itoa(size))
+
+	return nil
 }
 
 func writeDebugHeaders(rw http.ResponseWriter, result *processing.Result) {
@@ -146,13 +152,17 @@ func writeDebugHeaders(rw http.ResponseWriter, result *processing.Result) {
 	rw.Header().Set(httpheaders.XResultHeight, strconv.Itoa(result.ResultHeight))
 }
 
-func respondWithImage(reqID string, r *http.Request, rw http.ResponseWriter, statusCode int, resultData imagedata.ImageData, po *options.ProcessingOptions, originURL string, originData imagedata.ImageData, originHeaders http.Header) {
+func respondWithImage(reqID string, r *http.Request, rw http.ResponseWriter, statusCode int, resultData imagedata.ImageData, po *options.ProcessingOptions, originURL string, originHeaders http.Header) error {
 	// We read the size of the image data here, so we can set Content-Length header.
 	// This indireclty ensures that the image data is fully read from the source, no
 	// errors happened.
 	resultSize, err := resultData.Size()
 	if err != nil {
-		checkErr(r.Context(), "image_data_size", err)
+		return ierrors.Wrap(
+			err, 0,
+			ierrors.WithCategory(categoryImageDataSize),
+			ierrors.WithShouldReport(true),
+		)
 	}
 
 	contentDisposition := httpheaders.ContentDispositionValue(
@@ -183,18 +193,20 @@ func respondWithImage(reqID string, r *http.Request, rw http.ResponseWriter, sta
 		ierr = newResponseWriteError(err)
 
 		if config.ReportIOErrors {
-			sendErr(r.Context(), "IO", ierr)
+			sendErr(r.Context(), categoryIO, ierr)
 			errorreport.Report(ierr, r)
 		}
 	}
 
-	router.LogResponse(
+	server.LogResponse(
 		reqID, r, statusCode, ierr,
 		log.Fields{
 			"image_url":          originURL,
 			"processing_options": po,
 		},
 	)
+
+	return nil
 }
 
 func respondWithNotModified(reqID string, r *http.Request, rw http.ResponseWriter, po *options.ProcessingOptions, originURL string, originHeaders http.Header) {
@@ -202,7 +214,7 @@ func respondWithNotModified(reqID string, r *http.Request, rw http.ResponseWrite
 	setVary(rw)
 
 	rw.WriteHeader(304)
-	router.LogResponse(
+	server.LogResponse(
 		reqID, r, 304, nil,
 		log.Fields{
 			"image_url":          originURL,
@@ -229,19 +241,7 @@ func sendErr(ctx context.Context, errType string, err error) {
 	}
 }
 
-func sendErrAndPanic(ctx context.Context, errType string, err error) {
-	sendErr(ctx, errType, err)
-	panic(err)
-}
-
-func checkErr(ctx context.Context, errType string, err error) {
-	if err == nil {
-		return
-	}
-	sendErrAndPanic(ctx, errType, err)
-}
-
-func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
+func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) error {
 	stats.IncRequestsInProgress()
 	defer stats.DecRequestsInProgress()
 
@@ -263,19 +263,22 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 		signature = path[:signatureEnd]
 		path = path[signatureEnd:]
 	} else {
-		sendErrAndPanic(ctx, "path_parsing", newInvalidURLErrorf(
-			http.StatusNotFound, "Invalid path: %s", path),
+		return ierrors.Wrap(
+			newInvalidURLErrorf(http.StatusNotFound, "Invalid path: %s", path), 0,
+			ierrors.WithCategory(categoryPathParsing),
 		)
 	}
 
 	path = fixPath(path)
 
 	if err := security.VerifySignature(signature, path); err != nil {
-		sendErrAndPanic(ctx, "security", err)
+		return ierrors.Wrap(err, 0, ierrors.WithCategory(categorySecurity))
 	}
 
 	po, imageURL, err := options.ParsePath(path, r.Header)
-	checkErr(ctx, "path_parsing", err)
+	if err != nil {
+		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryPathParsing))
+	}
 
 	var imageOrigin any
 	if u, uerr := url.Parse(imageURL); uerr == nil {
@@ -295,19 +298,21 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 	metrics.SetMetadata(ctx, metricsMeta)
 
 	err = security.VerifySourceURL(imageURL)
-	checkErr(ctx, "security", err)
+	if err != nil {
+		return ierrors.Wrap(err, 0, ierrors.WithCategory(categorySecurity))
+	}
 
 	if po.Raw {
 		streamOriginImage(ctx, reqID, r, rw, po, imageURL)
-		return
+		return nil
 	}
 
 	// 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", newInvalidURLErrorf(
+		return ierrors.Wrap(newInvalidURLErrorf(
 			http.StatusUnprocessableEntity,
 			"Resulting image format is not supported: %s", po.Format,
-		))
+		), 0, ierrors.WithCategory(categoryPathParsing))
 	}
 
 	imgRequestHeader := make(http.Header)
@@ -339,21 +344,24 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 	}
 
 	// The heavy part starts here, so we need to restrict worker number
-	func() {
-		defer metrics.StartQueueSegment(ctx)()
 
-		err = processingSem.Acquire(ctx, 1)
+	err = processingSem.Acquire(ctx, 1)
+	if err != nil {
+		metrics.StartQueueSegment(ctx)()
+
+		// We don't actually need to check timeout here,
+		// but it's an easy way to check if this is an actual timeout
+		// or the request was canceled
+		if terr := server.CheckTimeout(ctx); terr != nil {
+			return ierrors.Wrap(terr, 0, ierrors.WithCategory(categoryTimeout))
+		}
+
 		if err != nil {
-			// We don't actually need to check timeout here,
-			// but it's an easy way to check if this is an actual timeout
-			// or the request was canceled
-			checkErr(ctx, "queue", router.CheckTimeout(ctx))
-			// We should never reach this line as err could be only ctx.Err()
-			// and we've already checked for it. But beter safe than sorry
-			sendErrAndPanic(ctx, "queue", err)
+			return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryQueue))
 		}
-	}()
+	}
 	defer processingSem.Release(1)
+	metrics.StartQueueSegment(ctx)()
 
 	stats.IncImagesInProgress()
 	defer stats.DecImagesInProgress()
@@ -375,7 +383,9 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 
 		if config.CookiePassthrough {
 			downloadOpts.CookieJar, err = cookies.JarFromRequest(r)
-			checkErr(ctx, "download", err)
+			if err != nil {
+				return nil, nil, ierrors.Wrap(err, 0, ierrors.WithCategory(categoryDownload))
+			}
 		}
 
 		return imagedata.DownloadAsync(ctx, imageURL, "source image", downloadOpts)
@@ -393,26 +403,29 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 		}
 
 		respondWithNotModified(reqID, r, rw, po, imageURL, nmErr.Headers())
-		return
+		return nil
 
 	default:
 		// This may be a request timeout error or a request cancelled error.
 		// Check it before moving further
-		checkErr(ctx, "timeout", router.CheckTimeout(ctx))
+		if terr := server.CheckTimeout(ctx); terr != nil {
+			return ierrors.Wrap(terr, 0, ierrors.WithCategory(categoryTimeout))
+		}
 
 		ierr := ierrors.Wrap(err, 0)
 		if config.ReportDownloadingErrors {
 			ierr = ierrors.Wrap(ierr, 0, ierrors.WithShouldReport(true))
 		}
 
-		sendErr(ctx, "download", ierr)
+		if ierr != nil {
+			metrics.SendError(ctx, categoryDownload, err)
+		}
 
 		if imagedata.FallbackImage == nil {
-			panic(ierr)
+			return ierr
 		}
 
-		// We didn't panic, so the error is not reported.
-		// Report it now
+		// Fallback image was present, however, we did not report it
 		if ierr.ShouldReport() {
 			errorreport.Report(ierr, r)
 		}
@@ -433,7 +446,9 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 		}
 	}
 
-	checkErr(ctx, "timeout", router.CheckTimeout(ctx))
+	if terr := server.CheckTimeout(ctx); terr != nil {
+		return ierrors.Wrap(terr, 0, ierrors.WithCategory(categoryTimeout))
+	}
 
 	if config.ETagEnabled && statusCode == http.StatusOK {
 		imgDataMatch, terr := etagHandler.SetActualImageData(originData, originHeaders)
@@ -442,18 +457,20 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 
 			if imgDataMatch && etagHandler.ProcessingOptionsMatch() {
 				respondWithNotModified(reqID, r, rw, po, imageURL, originHeaders)
-				return
+				return nil
 			}
 		}
 	}
 
-	checkErr(ctx, "timeout", router.CheckTimeout(ctx))
+	if terr := server.CheckTimeout(ctx); terr != nil {
+		return ierrors.Wrap(terr, 0, ierrors.WithCategory(categoryTimeout))
+	}
 
 	if !vips.SupportsLoad(originData.Format()) {
-		sendErrAndPanic(ctx, "processing", newInvalidURLErrorf(
+		return ierrors.Wrap(newInvalidURLErrorf(
 			http.StatusUnprocessableEntity,
 			"Source image format is not supported: %s", originData.Format(),
-		))
+		), 0, ierrors.WithCategory(categoryProcessing))
 	}
 
 	result, err := func() (*processing.Result, error) {
@@ -470,16 +487,24 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 
 	if err != nil {
 		// First, check if the processing error wasn't caused by an image data error
-		checkErr(ctx, "download", originData.Error())
+		if originData.Error() != nil {
+			return ierrors.Wrap(originData.Error(), 0, ierrors.WithCategory(categoryDownload))
+		}
 
 		// If it wasn't, than it was a processing error
-		sendErrAndPanic(ctx, "processing", err)
+		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryProcessing))
 	}
 
-	checkErr(ctx, "timeout", router.CheckTimeout(ctx))
+	if err := server.CheckTimeout(ctx); err != nil {
+		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryTimeout))
+	}
 
 	writeDebugHeaders(rw, result)
-	writeOriginContentLengthDebugHeader(ctx, rw, originData)
+	if err := writeOriginContentLengthDebugHeader(rw, originData); err != nil {
+		return err
+	}
+
+	respondWithImage(reqID, r, rw, statusCode, result.OutData, po, imageURL, originHeaders)
 
-	respondWithImage(reqID, r, rw, statusCode, result.OutData, po, imageURL, originData, originHeaders)
+	return nil
 }

+ 4 - 3
processing_handler_test.go

@@ -22,7 +22,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/imagetype"
 	"github.com/imgproxy/imgproxy/v3/options"
-	"github.com/imgproxy/imgproxy/v3/router"
+	"github.com/imgproxy/imgproxy/v3/server"
 	"github.com/imgproxy/imgproxy/v3/svg"
 	"github.com/imgproxy/imgproxy/v3/testutil"
 	"github.com/imgproxy/imgproxy/v3/vips"
@@ -31,7 +31,7 @@ import (
 type ProcessingHandlerTestSuite struct {
 	suite.Suite
 
-	router *router.Router
+	router *server.Router
 }
 
 func (s *ProcessingHandlerTestSuite) SetupSuite() {
@@ -48,7 +48,8 @@ func (s *ProcessingHandlerTestSuite) SetupSuite() {
 
 	logrus.SetOutput(io.Discard)
 
-	s.router = buildRouter()
+	r := server.NewRouter(server.NewConfigFromEnv())
+	s.router = buildRouter(r)
 }
 
 func (s *ProcessingHandlerTestSuite) TeardownSuite() {

+ 2 - 4
reuseport/listen_no_reuseport.go

@@ -7,12 +7,10 @@ import (
 	"net"
 
 	log "github.com/sirupsen/logrus"
-
-	"github.com/imgproxy/imgproxy/v3/config"
 )
 
-func Listen(network, address string) (net.Listener, error) {
-	if config.SoReuseport {
+func Listen(network, address string, reuse bool) (net.Listener, error) {
+	if reuse {
 		log.Warning("SO_REUSEPORT support is not implemented for your OS or Go version")
 	}
 

+ 2 - 4
reuseport/listen_reuseport.go

@@ -10,12 +10,10 @@ import (
 	"syscall"
 
 	"golang.org/x/sys/unix"
-
-	"github.com/imgproxy/imgproxy/v3/config"
 )
 
-func Listen(network, address string) (net.Listener, error) {
-	if !config.SoReuseport {
+func Listen(network, address string, reuse bool) (net.Listener, error) {
+	if !reuse {
 		return net.Listen(network, address)
 	}
 

+ 0 - 204
server.go

@@ -1,204 +0,0 @@
-package main
-
-import (
-	"context"
-	"crypto/subtle"
-	"fmt"
-	golog "log"
-	"net/http"
-	"time"
-
-	log "github.com/sirupsen/logrus"
-	"golang.org/x/net/netutil"
-
-	"github.com/imgproxy/imgproxy/v3/config"
-	"github.com/imgproxy/imgproxy/v3/errorreport"
-	"github.com/imgproxy/imgproxy/v3/ierrors"
-	"github.com/imgproxy/imgproxy/v3/metrics"
-	"github.com/imgproxy/imgproxy/v3/reuseport"
-	"github.com/imgproxy/imgproxy/v3/router"
-	"github.com/imgproxy/imgproxy/v3/vips"
-)
-
-var imgproxyIsRunningMsg = []byte("imgproxy is running")
-
-func buildRouter() *router.Router {
-	r := router.New(config.PathPrefix)
-
-	r.GET("/", handleLanding, true)
-	r.GET("", handleLanding, true)
-
-	r.GET("/", withMetrics(withPanicHandler(withCORS(withSecret(handleProcessing)))), false)
-
-	r.HEAD("/", withCORS(handleHead), false)
-	r.OPTIONS("/", withCORS(handleHead), false)
-
-	r.HealthHandler = handleHealth
-
-	return r
-}
-
-func startServer(cancel context.CancelFunc) (*http.Server, error) {
-	l, err := reuseport.Listen(config.Network, config.Bind)
-	if err != nil {
-		return nil, fmt.Errorf("can't start server: %s", err)
-	}
-
-	if config.MaxClients > 0 {
-		l = netutil.LimitListener(l, config.MaxClients)
-	}
-
-	errLogger := golog.New(
-		log.WithField("source", "http_server").WriterLevel(log.ErrorLevel),
-		"", 0,
-	)
-
-	s := &http.Server{
-		Handler:        buildRouter(),
-		ReadTimeout:    time.Duration(config.ReadRequestTimeout) * time.Second,
-		MaxHeaderBytes: 1 << 20,
-		ErrorLog:       errLogger,
-	}
-
-	if config.KeepAliveTimeout > 0 {
-		s.IdleTimeout = time.Duration(config.KeepAliveTimeout) * time.Second
-	} else {
-		s.SetKeepAlivesEnabled(false)
-	}
-
-	go func() {
-		log.Infof("Starting server at %s", config.Bind)
-		if err := s.Serve(l); err != nil && err != http.ErrServerClosed {
-			log.Error(err)
-		}
-		cancel()
-	}()
-
-	return s, nil
-}
-
-func shutdownServer(s *http.Server) {
-	log.Info("Shutting down the server...")
-
-	ctx, close := context.WithTimeout(context.Background(), 5*time.Second)
-	defer close()
-
-	s.Shutdown(ctx)
-}
-
-func withMetrics(h router.RouteHandler) router.RouteHandler {
-	if !metrics.Enabled() {
-		return h
-	}
-
-	return func(reqID string, rw http.ResponseWriter, r *http.Request) {
-		ctx, metricsCancel, rw := metrics.StartRequest(r.Context(), rw, r)
-		defer metricsCancel()
-
-		h(reqID, rw, r.WithContext(ctx))
-	}
-}
-
-func withCORS(h router.RouteHandler) router.RouteHandler {
-	return func(reqID string, rw http.ResponseWriter, r *http.Request) {
-		if len(config.AllowOrigin) > 0 {
-			rw.Header().Set("Access-Control-Allow-Origin", config.AllowOrigin)
-			rw.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
-		}
-
-		h(reqID, rw, r)
-	}
-}
-
-func withSecret(h router.RouteHandler) router.RouteHandler {
-	if len(config.Secret) == 0 {
-		return h
-	}
-
-	authHeader := []byte(fmt.Sprintf("Bearer %s", config.Secret))
-
-	return func(reqID string, rw http.ResponseWriter, r *http.Request) {
-		if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), authHeader) == 1 {
-			h(reqID, rw, r)
-		} else {
-			panic(newInvalidSecretError())
-		}
-	}
-}
-
-func withPanicHandler(h router.RouteHandler) router.RouteHandler {
-	return func(reqID string, rw http.ResponseWriter, r *http.Request) {
-		ctx := errorreport.StartRequest(r)
-		r = r.WithContext(ctx)
-
-		errorreport.SetMetadata(r, "Request ID", reqID)
-
-		defer func() {
-			if rerr := recover(); rerr != nil {
-				if rerr == http.ErrAbortHandler {
-					panic(rerr)
-				}
-
-				err, ok := rerr.(error)
-				if !ok {
-					panic(rerr)
-				}
-
-				ierr := ierrors.Wrap(err, 0)
-
-				if ierr.ShouldReport() {
-					errorreport.Report(err, r)
-				}
-
-				router.LogResponse(reqID, r, ierr.StatusCode(), ierr)
-
-				rw.Header().Set("Content-Type", "text/plain")
-				rw.WriteHeader(ierr.StatusCode())
-
-				if config.DevelopmentErrorsMode {
-					rw.Write([]byte(ierr.Error()))
-				} else {
-					rw.Write([]byte(ierr.PublicMessage()))
-				}
-			}
-		}()
-
-		h(reqID, rw, r)
-	}
-}
-
-func handleHealth(reqID string, rw http.ResponseWriter, r *http.Request) {
-	var (
-		status int
-		msg    []byte
-		ierr   *ierrors.Error
-	)
-
-	if err := vips.Health(); err == nil {
-		status = http.StatusOK
-		msg = imgproxyIsRunningMsg
-	} else {
-		status = http.StatusInternalServerError
-		msg = []byte("Error")
-		ierr = ierrors.Wrap(err, 1)
-	}
-
-	if len(msg) == 0 {
-		msg = []byte{' '}
-	}
-
-	// Log response only if something went wrong
-	if ierr != nil {
-		router.LogResponse(reqID, r, status, ierr)
-	}
-
-	rw.Header().Set("Content-Type", "text/plain")
-	rw.Header().Set("Cache-Control", "no-cache")
-	rw.WriteHeader(status)
-	rw.Write(msg)
-}
-
-func handleHead(reqID string, rw http.ResponseWriter, r *http.Request) {
-	router.LogResponse(reqID, r, 200, nil)
-	rw.WriteHeader(200)
-}

+ 47 - 0
server/config.go

@@ -0,0 +1,47 @@
+package server
+
+import (
+	"time"
+
+	"github.com/imgproxy/imgproxy/v3/config"
+)
+
+const (
+	// gracefulTimeout represents graceful shutdown timeout
+	gracefulTimeout = time.Duration(5 * time.Second)
+)
+
+// Config represents HTTP server config
+type Config struct {
+	Listen                string        // Address to listen on
+	Network               string        // Network type (tcp, unix)
+	Bind                  string        // Bind address
+	PathPrefix            string        // Path prefix for the server
+	MaxClients            int           // Maximum number of concurrent clients
+	ReadRequestTimeout    time.Duration // Timeout for reading requests
+	KeepAliveTimeout      time.Duration // Timeout for keep-alive connections
+	GracefulTimeout       time.Duration // Timeout for graceful shutdown
+	CORSAllowOrigin       string        // CORS allowed origin
+	Secret                string        // Secret for authorization
+	DevelopmentErrorsMode bool          // Enable development mode for detailed error messages
+	SocketReusePort       bool          // Enable SO_REUSEPORT socket option
+	HealthCheckPath       string        // Health check path from config
+}
+
+// NewConfigFromEnv creates a new Config instance from environment variables
+func NewConfigFromEnv() *Config {
+	return &Config{
+		Network:               config.Network,
+		Bind:                  config.Bind,
+		PathPrefix:            config.PathPrefix,
+		MaxClients:            config.MaxClients,
+		ReadRequestTimeout:    time.Duration(config.ReadRequestTimeout) * time.Second,
+		KeepAliveTimeout:      time.Duration(config.KeepAliveTimeout) * time.Second,
+		GracefulTimeout:       gracefulTimeout,
+		CORSAllowOrigin:       config.AllowOrigin,
+		Secret:                config.Secret,
+		DevelopmentErrorsMode: config.DevelopmentErrorsMode,
+		SocketReusePort:       config.SoReuseport,
+		HealthCheckPath:       config.HealthCheckPath,
+	}
+}

+ 15 - 1
router/errors.go → server/errors.go

@@ -1,4 +1,4 @@
-package router
+package server
 
 import (
 	"fmt"
@@ -12,6 +12,7 @@ type (
 	RouteNotDefinedError  string
 	RequestCancelledError string
 	RequestTimeoutError   string
+	InvalidSecretError    struct{}
 )
 
 func newRouteNotDefinedError(path string) *ierrors.Error {
@@ -44,8 +45,21 @@ func newRequestTimeoutError(after time.Duration) *ierrors.Error {
 		1,
 		ierrors.WithStatusCode(http.StatusServiceUnavailable),
 		ierrors.WithPublicMessage("Gateway Timeout"),
+		ierrors.WithCategory("timeout"),
 		ierrors.WithShouldReport(false),
 	)
 }
 
 func (e RequestTimeoutError) Error() string { return string(e) }
+
+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" }

+ 1 - 1
router/logging.go → server/logging.go

@@ -1,4 +1,4 @@
-package router
+package server
 
 import (
 	"net"

+ 38 - 30
router/router.go → server/router.go

@@ -1,19 +1,20 @@
-package router
+// router represents our HTTP server routes
+package server
 
 import (
 	"encoding/json"
 	"net"
 	"net/http"
 	"regexp"
+	"slices"
 	"strings"
 
 	nanoid "github.com/matoous/go-nanoid/v2"
 
-	"github.com/imgproxy/imgproxy/v3/config"
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
 )
 
 const (
-	xRequestIDHeader          = "X-Request-ID"
 	xAmznRequestContextHeader = "x-amzn-request-context"
 )
 
@@ -21,8 +22,10 @@ var (
 	requestIDRe = regexp.MustCompile(`^[A-Za-z0-9_\-]+$`)
 )
 
-type RouteHandler func(string, http.ResponseWriter, *http.Request)
+// RouteHandler represents error handler which might return an error
+type RouteHandler func(string, http.ResponseWriter, *http.Request) error
 
+// route represents route
 type route struct {
 	Method  string
 	Prefix  string
@@ -30,7 +33,9 @@ type route struct {
 	Exact   bool
 }
 
+// Router represents current server router
 type Router struct {
+	config       *Config
 	prefix       string
 	healthRoutes []string
 	faviconRoute string
@@ -39,32 +44,25 @@ type Router struct {
 	HealthHandler RouteHandler
 }
 
-func (r *route) isMatch(req *http.Request) bool {
-	if r.Method != req.Method {
-		return false
-	}
-
-	if r.Exact {
-		return req.URL.Path == r.Prefix
-	}
-
-	return strings.HasPrefix(req.URL.Path, r.Prefix)
-}
+// NewRouter creates a new router
+func NewRouter(config *Config) *Router {
+	prefix := config.PathPrefix
 
-func New(prefix string) *Router {
 	healthRoutes := []string{prefix + "/health"}
 	if len(config.HealthCheckPath) > 0 {
 		healthRoutes = append(healthRoutes, prefix+config.HealthCheckPath)
 	}
 
 	return &Router{
+		config:       config,
 		prefix:       prefix,
 		healthRoutes: healthRoutes,
 		faviconRoute: prefix + "/favicon.ico",
-		Routes:       make([]*route, 0),
+		Routes:       nil,
 	}
 }
 
+// Add adds new route to the set
 func (r *Router) Add(method, prefix string, handler RouteHandler, exact bool) {
 	// Don't add routes with empty prefix
 	if len(r.prefix+prefix) == 0 {
@@ -95,7 +93,7 @@ func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
 
 	rw = newTimeoutResponse(rw)
 
-	reqID := req.Header.Get(xRequestIDHeader)
+	reqID := req.Header.Get(httpheaders.XRequestID)
 
 	if len(reqID) == 0 || !requestIDRe.MatchString(reqID) {
 		if lambdaContextVal := req.Header.Get(xAmznRequestContextHeader); len(lambdaContextVal) > 0 {
@@ -114,22 +112,20 @@ func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
 		reqID, _ = nanoid.New()
 	}
 
-	rw.Header().Set("Server", "imgproxy")
-	rw.Header().Set(xRequestIDHeader, reqID)
+	rw.Header().Set(httpheaders.Server, "imgproxy")
+	rw.Header().Set(httpheaders.XRequestID, reqID)
 
 	if req.Method == http.MethodGet {
 		if r.HealthHandler != nil {
-			for _, healthRoute := range r.healthRoutes {
-				if req.URL.Path == healthRoute {
-					r.HealthHandler(reqID, rw, req)
-					return
-				}
+			if slices.Contains(r.healthRoutes, req.URL.Path) {
+				r.HealthHandler(reqID, rw, req)
+				return
 			}
 		}
 
 		if req.URL.Path == r.faviconRoute {
 			// TODO: Add a real favicon maybe?
-			rw.Header().Set("Content-Type", "text/plain")
+			rw.Header().Set(httpheaders.ContentType, "text/plain")
 			rw.WriteHeader(404)
 			// Write a single byte to make AWS Lambda happy
 			rw.Write([]byte{' '})
@@ -139,12 +135,12 @@ func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
 
 	if ip := req.Header.Get("CF-Connecting-IP"); len(ip) != 0 {
 		replaceRemoteAddr(req, ip)
-	} else if ip := req.Header.Get("X-Forwarded-For"); len(ip) != 0 {
+	} else if ip := req.Header.Get(httpheaders.XForwardedFor); len(ip) != 0 {
 		if index := strings.Index(ip, ","); index > 0 {
 			ip = ip[:index]
 		}
 		replaceRemoteAddr(req, ip)
-	} else if ip := req.Header.Get("X-Real-IP"); len(ip) != 0 {
+	} else if ip := req.Header.Get(httpheaders.XRealIP); len(ip) != 0 {
 		replaceRemoteAddr(req, ip)
 	}
 
@@ -159,8 +155,8 @@ func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
 
 	LogResponse(reqID, req, 404, newRouteNotDefinedError(req.URL.Path))
 
-	rw.Header().Set("Content-Type", "text/plain")
-	rw.WriteHeader(404)
+	rw.Header().Set(httpheaders.ContentType, "text/plain")
+	rw.WriteHeader(http.StatusNotFound)
 	rw.Write([]byte{' '})
 }
 
@@ -172,3 +168,15 @@ func replaceRemoteAddr(req *http.Request, ip string) {
 
 	req.RemoteAddr = net.JoinHostPort(strings.TrimSpace(ip), port)
 }
+
+func (r *route) isMatch(req *http.Request) bool {
+	if r.Method != req.Method {
+		return false
+	}
+
+	if r.Exact {
+		return req.URL.Path == r.Prefix
+	}
+
+	return strings.HasPrefix(req.URL.Path, r.Prefix)
+}

+ 141 - 0
server/router_middlewares.go

@@ -0,0 +1,141 @@
+package server
+
+import (
+	"crypto/subtle"
+	"fmt"
+	"net/http"
+
+	"github.com/imgproxy/imgproxy/v3/errorreport"
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
+	"github.com/imgproxy/imgproxy/v3/ierrors"
+	"github.com/imgproxy/imgproxy/v3/metrics"
+)
+
+// WithMetrics wraps RouteHandler with metrics handling.
+func (ro *Router) WithMetrics(h RouteHandler) RouteHandler {
+	if !metrics.Enabled() {
+		return h
+	}
+
+	return func(reqID string, rw http.ResponseWriter, r *http.Request) error {
+		ctx, metricsCancel, rw := metrics.StartRequest(r.Context(), rw, r)
+		defer metricsCancel()
+
+		return h(reqID, rw, r.WithContext(ctx))
+	}
+}
+
+// WithCORS wraps RouteHandler with CORS handling
+func (ro *Router) WithCORS(h RouteHandler) RouteHandler {
+	if len(ro.config.CORSAllowOrigin) == 0 {
+		return h
+	}
+
+	return func(reqID string, rw http.ResponseWriter, r *http.Request) error {
+		rw.Header().Set(httpheaders.AccessControlAllowOrigin, ro.config.CORSAllowOrigin)
+		rw.Header().Set(httpheaders.AccessControlAllowMethods, "GET, OPTIONS")
+
+		return h(reqID, rw, r)
+	}
+}
+
+// WithSecret wraps RouteHandler with secret handling
+func (ro *Router) WithSecret(h RouteHandler) RouteHandler {
+	if len(ro.config.Secret) == 0 {
+		return h
+	}
+
+	authHeader := fmt.Appendf(nil, "Bearer %s", ro.config.Secret)
+
+	return func(reqID string, rw http.ResponseWriter, r *http.Request) error {
+		if subtle.ConstantTimeCompare([]byte(r.Header.Get(httpheaders.Authorization)), authHeader) == 1 {
+			return h(reqID, rw, r)
+		} else {
+			return newInvalidSecretError()
+		}
+	}
+}
+
+// WithReportError handles error reporting.
+// It should be placed after `WithMetrics`, but before `WithPanic`.
+func (ro *Router) WithReportError(h RouteHandler) RouteHandler {
+	return func(reqID string, rw http.ResponseWriter, r *http.Request) error {
+		// Open the error context
+		ctx := errorreport.StartRequest(r)
+		r = r.WithContext(ctx)
+		errorreport.SetMetadata(r, "Request ID", reqID)
+
+		// Call the underlying handler passing the context downwards
+		err := h(reqID, rw, r)
+
+		if err == nil {
+			return nil
+		}
+
+		// Wrap a resulting error into ierrors.Error
+		ierr := ierrors.Wrap(err, 0)
+
+		// Get the error category
+		errCat := ierr.Category()
+
+		// Report error to metrics (if metrics are disabled, it will be a no-op)
+		if ierr.StatusCode() != 499 {
+			metrics.SendError(ctx, errCat, err)
+		}
+
+		// Report error to error collectors
+		if ierr.ShouldReport() {
+			errorreport.Report(ierr, r)
+		}
+
+		// Log response and format the error output
+		LogResponse(reqID, r, ierr.StatusCode(), ierr)
+
+		// Error message: either is public message or full development error
+		rw.Header().Set(httpheaders.ContentType, "text/plain")
+		rw.WriteHeader(ierr.StatusCode())
+
+		if ro.config.DevelopmentErrorsMode {
+			rw.Write([]byte(ierr.Error()))
+		} else {
+			rw.Write([]byte(ierr.PublicMessage()))
+		}
+
+		return nil
+	}
+}
+
+// WithPanic recovers panic and converts it to normal error
+func (ro *Router) WithPanic(h RouteHandler) RouteHandler {
+	return func(reqID string, rw http.ResponseWriter, r *http.Request) (retErr error) {
+		defer func() {
+			// try to recover from panic
+			rerr := recover()
+			if rerr == nil {
+				return
+			}
+
+			// abort handler is an exception of net/http, we should simply repanic it.
+			// it will supress the stack trace
+			if rerr == http.ErrAbortHandler {
+				panic(rerr)
+			}
+
+			// let's recover error value from panic if it has panicked with error
+			err, ok := rerr.(error)
+			if !ok {
+				err = fmt.Errorf("panic: %v", err)
+			}
+
+			// wrap ierror unless already done
+			ierr := ierrors.Wrap(err, 0)
+			if ierr.ShouldReport() {
+				errorreport.Report(ierr, r)
+			}
+
+			retErr = ierr
+		}()
+
+		return h(reqID, rw, r)
+	}
+}

+ 83 - 0
server/server.go

@@ -0,0 +1,83 @@
+package server
+
+import (
+	"context"
+	"fmt"
+	golog "log"
+	"net/http"
+
+	log "github.com/sirupsen/logrus"
+	"golang.org/x/net/netutil"
+
+	"github.com/imgproxy/imgproxy/v3/reuseport"
+)
+
+const (
+	// maxHeaderBytes represents max bytes in request header
+	maxHeaderBytes = 1 << 20
+)
+
+// Server represents the HTTP server wrapper struct
+type Server struct {
+	router *Router
+	config *Config
+	server *http.Server
+}
+
+// Start starts the http server. cancel is called in case server failed to start, but it happened
+// asynchronously. It should cancel the upstream context.
+func Start(cancel context.CancelFunc, router *Router, config *Config) (*Server, error) {
+	l, err := reuseport.Listen(config.Network, config.Bind, config.SocketReusePort)
+	if err != nil {
+		cancel()
+		return nil, fmt.Errorf("can't start server: %s", err)
+	}
+
+	if config.MaxClients > 0 {
+		l = netutil.LimitListener(l, config.MaxClients)
+	}
+
+	errLogger := golog.New(
+		log.WithField("source", "http_server").WriterLevel(log.ErrorLevel),
+		"", 0,
+	)
+
+	srv := &http.Server{
+		Handler:        router,
+		ReadTimeout:    config.ReadRequestTimeout,
+		MaxHeaderBytes: maxHeaderBytes,
+		ErrorLog:       errLogger,
+	}
+
+	if config.KeepAliveTimeout > 0 {
+		srv.IdleTimeout = config.KeepAliveTimeout
+	} else {
+		srv.SetKeepAlivesEnabled(false)
+	}
+
+	go func() {
+		log.Infof("Starting server at %s", config.Bind)
+
+		if err := srv.Serve(l); err != nil && err != http.ErrServerClosed {
+			log.Error(err)
+		}
+
+		cancel()
+	}()
+
+	return &Server{
+		router: router,
+		config: config,
+		server: srv,
+	}, nil
+}
+
+// Shutdown gracefully shuts down the server
+func (s *Server) Shutdown(ctx context.Context) {
+	log.Info("Shutting down the server...")
+
+	ctx, close := context.WithTimeout(ctx, s.config.GracefulTimeout)
+	defer close()
+
+	s.server.Shutdown(ctx)
+}

+ 266 - 0
server/server_test.go

@@ -0,0 +1,266 @@
+package server
+
+import (
+	"context"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/imgproxy/imgproxy/v3/config"
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
+	"github.com/stretchr/testify/suite"
+)
+
+type ServerTestSuite struct {
+	suite.Suite
+	config      *Config
+	blankRouter *Router
+}
+
+func (s *ServerTestSuite) SetupTest() {
+	config.Reset()
+	s.config = NewConfigFromEnv()
+	s.config.Bind = "127.0.0.1:0" // Use port 0 for auto-assignment
+	s.blankRouter = NewRouter(s.config)
+}
+
+func (s *ServerTestSuite) mockHandler(reqID string, rw http.ResponseWriter, r *http.Request) error {
+	return nil
+}
+
+func (s *ServerTestSuite) TestStartServerWithInvalidBind() {
+	ctx, cancel := context.WithCancel(s.T().Context())
+
+	// Track if cancel was called using atomic
+	var cancelCalled atomic.Bool
+	cancelWrapper := func() {
+		cancel()
+		cancelCalled.Store(true)
+	}
+
+	invalidConfig := &Config{
+		Network: "tcp",
+		Bind:    "invalid-address", // Invalid address
+	}
+
+	server, err := Start(cancelWrapper, s.blankRouter, invalidConfig)
+
+	s.Require().Error(err)
+	s.Nil(server)
+	s.Contains(err.Error(), "can't start server")
+
+	// Check if cancel was called using Eventually
+	s.Require().Eventually(cancelCalled.Load, 100*time.Millisecond, 10*time.Millisecond)
+
+	// Also verify the context was cancelled
+	s.Require().Eventually(func() bool {
+		select {
+		case <-ctx.Done():
+			return true
+		default:
+			return false
+		}
+	}, 100*time.Millisecond, 10*time.Millisecond)
+}
+
+func (s *ServerTestSuite) TestShutdown() {
+	_, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	server, err := Start(cancel, s.blankRouter, s.config)
+	s.Require().NoError(err)
+	s.NotNil(server)
+
+	// Test graceful shutdown
+	shutdownCtx, shutdownCancel := context.WithTimeout(s.T().Context(), 10*time.Second)
+	defer shutdownCancel()
+
+	// Should not panic or hang
+	s.NotPanics(func() {
+		server.Shutdown(shutdownCtx)
+	})
+}
+
+func (s *ServerTestSuite) TestWithCORS() {
+	tests := []struct {
+		name            string
+		corsAllowOrigin string
+		expectedOrigin  string
+		expectedMethods string
+	}{
+		{
+			name:            "WithCORSOrigin",
+			corsAllowOrigin: "https://example.com",
+			expectedOrigin:  "https://example.com",
+			expectedMethods: "GET, OPTIONS",
+		},
+		{
+			name:            "NoCORSOrigin",
+			corsAllowOrigin: "",
+			expectedOrigin:  "",
+			expectedMethods: "",
+		},
+	}
+
+	for _, tt := range tests {
+		s.Run(tt.name, func() {
+			config := &Config{
+				CORSAllowOrigin: tt.corsAllowOrigin,
+			}
+			router := NewRouter(config)
+
+			wrappedHandler := router.WithCORS(s.mockHandler)
+
+			req := httptest.NewRequest("GET", "/test", nil)
+			rw := httptest.NewRecorder()
+
+			wrappedHandler("test-req-id", rw, req)
+
+			s.Equal(tt.expectedOrigin, rw.Header().Get(httpheaders.AccessControlAllowOrigin))
+			s.Equal(tt.expectedMethods, rw.Header().Get(httpheaders.AccessControlAllowMethods))
+		})
+	}
+}
+
+func (s *ServerTestSuite) TestWithSecret() {
+	tests := []struct {
+		name        string
+		secret      string
+		authHeader  string
+		expectError bool
+	}{
+		{
+			name:       "ValidSecret",
+			secret:     "test-secret",
+			authHeader: "Bearer test-secret",
+		},
+		{
+			name:        "InvalidSecret",
+			secret:      "foo-secret",
+			authHeader:  "Bearer wrong-secret",
+			expectError: true,
+		},
+		{
+			name:       "NoSecretConfigured",
+			secret:     "",
+			authHeader: "",
+		},
+	}
+
+	for _, tt := range tests {
+		s.Run(tt.name, func() {
+			config := &Config{
+				Secret: tt.secret,
+			}
+			router := NewRouter(config)
+
+			wrappedHandler := router.WithSecret(s.mockHandler)
+
+			req := httptest.NewRequest("GET", "/test", nil)
+			if tt.authHeader != "" {
+				req.Header.Set(httpheaders.Authorization, tt.authHeader)
+			}
+			rw := httptest.NewRecorder()
+
+			err := wrappedHandler("test-req-id", rw, req)
+
+			if tt.expectError {
+				s.Require().Error(err)
+			} else {
+				s.Require().NoError(err)
+			}
+		})
+	}
+}
+
+func (s *ServerTestSuite) TestIntoSuccess() {
+	mockHandler := func(reqID string, rw http.ResponseWriter, r *http.Request) error {
+		rw.WriteHeader(http.StatusOK)
+		return nil
+	}
+
+	wrappedHandler := s.blankRouter.WithReportError(mockHandler)
+
+	req := httptest.NewRequest("GET", "/test", nil)
+	rw := httptest.NewRecorder()
+
+	wrappedHandler("test-req-id", rw, req)
+
+	s.Equal(http.StatusOK, rw.Code)
+}
+
+func (s *ServerTestSuite) TestIntoWithError() {
+	testError := errors.New("test error")
+	mockHandler := func(reqID string, rw http.ResponseWriter, r *http.Request) error {
+		return testError
+	}
+
+	wrappedHandler := s.blankRouter.WithReportError(mockHandler)
+
+	req := httptest.NewRequest("GET", "/test", nil)
+	rw := httptest.NewRecorder()
+
+	wrappedHandler("test-req-id", rw, req)
+
+	s.Equal(http.StatusInternalServerError, rw.Code)
+	s.Equal("text/plain", rw.Header().Get(httpheaders.ContentType))
+}
+
+func (s *ServerTestSuite) TestIntoPanicWithError() {
+	testError := errors.New("panic error")
+	mockHandler := func(reqID string, rw http.ResponseWriter, r *http.Request) error {
+		panic(testError)
+	}
+
+	wrappedHandler := s.blankRouter.WithPanic(mockHandler)
+
+	req := httptest.NewRequest("GET", "/test", nil)
+	rw := httptest.NewRecorder()
+
+	s.NotPanics(func() {
+		err := wrappedHandler("test-req-id", rw, req)
+		s.Require().Error(err, "panic error")
+	})
+
+	s.Equal(http.StatusOK, rw.Code)
+}
+
+func (s *ServerTestSuite) TestIntoPanicWithAbortHandler() {
+	mockHandler := func(reqID string, rw http.ResponseWriter, r *http.Request) error {
+		panic(http.ErrAbortHandler)
+	}
+
+	wrappedHandler := s.blankRouter.WithPanic(mockHandler)
+
+	req := httptest.NewRequest("GET", "/test", nil)
+	rw := httptest.NewRecorder()
+
+	// Should re-panic with ErrAbortHandler
+	s.Panics(func() {
+		wrappedHandler("test-req-id", rw, req)
+	})
+}
+
+func (s *ServerTestSuite) TestIntoPanicWithNonError() {
+	mockHandler := func(reqID string, rw http.ResponseWriter, r *http.Request) error {
+		panic("string panic")
+	}
+
+	wrappedHandler := s.blankRouter.WithPanic(mockHandler)
+
+	req := httptest.NewRequest("GET", "/test", nil)
+	rw := httptest.NewRecorder()
+
+	// Should re-panic with non-error panics
+	s.NotPanics(func() {
+		err := wrappedHandler("test-req-id", rw, req)
+		s.Require().Error(err, "string panic")
+	})
+}
+
+func TestServerTestSuite(t *testing.T) {
+	suite.Run(t, new(ServerTestSuite))
+}

+ 1 - 1
router/timeout_response.go → server/timeout_response.go

@@ -1,4 +1,4 @@
-package router
+package server
 
 import (
 	"net/http"

+ 1 - 1
router/timer.go → server/timer.go

@@ -1,4 +1,4 @@
-package router
+package server
 
 import (
 	"context"

+ 15 - 6
stream.go

@@ -12,11 +12,12 @@ import (
 	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/cookies"
 	"github.com/imgproxy/imgproxy/v3/httpheaders"
+	"github.com/imgproxy/imgproxy/v3/ierrors"
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/metrics"
 	"github.com/imgproxy/imgproxy/v3/metrics/stats"
 	"github.com/imgproxy/imgproxy/v3/options"
-	"github.com/imgproxy/imgproxy/v3/router"
+	"github.com/imgproxy/imgproxy/v3/server"
 )
 
 var (
@@ -44,7 +45,7 @@ var (
 	}
 )
 
-func streamOriginImage(ctx context.Context, reqID string, r *http.Request, rw http.ResponseWriter, po *options.ProcessingOptions, imageURL string) {
+func streamOriginImage(ctx context.Context, reqID string, r *http.Request, rw http.ResponseWriter, po *options.ProcessingOptions, imageURL string) error {
 	stats.IncImagesInProgress()
 	defer stats.DecImagesInProgress()
 
@@ -65,18 +66,24 @@ func streamOriginImage(ctx context.Context, reqID string, r *http.Request, rw ht
 
 	if config.CookiePassthrough {
 		cookieJar, err = cookies.JarFromRequest(r)
-		checkErr(ctx, "streaming", err)
+		if err != nil {
+			return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryStreaming))
+		}
 	}
 
 	req, err := imagedata.Fetcher.BuildRequest(r.Context(), imageURL, imgRequestHeader, cookieJar)
 	defer req.Cancel()
-	checkErr(ctx, "streaming", err)
+	if err != nil {
+		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryStreaming))
+	}
 
 	res, err := req.Send()
 	if res != nil {
 		defer res.Body.Close()
 	}
-	checkErr(ctx, "streaming", err)
+	if err != nil {
+		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryStreaming))
+	}
 
 	for _, k := range streamRespHeaders {
 		vv := res.Header.Values(k)
@@ -116,7 +123,7 @@ func streamOriginImage(ctx context.Context, reqID string, r *http.Request, rw ht
 		copyerr = nil
 	}
 
-	router.LogResponse(
+	server.LogResponse(
 		reqID, r, res.StatusCode, nil,
 		log.Fields{
 			"image_url":          imageURL,
@@ -127,4 +134,6 @@ func streamOriginImage(ctx context.Context, reqID string, r *http.Request, rw ht
 	if copyerr != nil {
 		panic(http.ErrAbortHandler)
 	}
+
+	return nil
 }