From 6f9374e5033ba312c75cc38df8104541f6dba623 Mon Sep 17 00:00:00 2001 From: tlnarayana005 Date: Mon, 6 Jul 2026 23:14:44 +0530 Subject: [PATCH] feat(gateway): make request body size limit configurable via MAX_REQUEST_BODY_BYTES Replace hardcoded 10MB body size limit in handleSummarize and CacheMiddleware with a configurable MAX_REQUEST_BODY_BYTES env var (default 10485760). Add formatBytes() for dynamic 413 error messages, unit tests, .env.example entry, and README config table row. --- .env.example | 3 ++ README.md | 1 + gateway/cache.go | 10 +++--- gateway/config.go | 39 ++++++++++++++++++++ gateway/config_test.go | 82 ++++++++++++++++++++++++++++++++++++++++++ gateway/main.go | 8 ++--- 6 files changed, 134 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index bdfda407..1413cfb7 100644 --- a/.env.example +++ b/.env.example @@ -89,6 +89,9 @@ AI_REQUEST_TIMEOUT_SECONDS=30 VERIFIER_TIMEOUT_SECONDS=2 # Health check timeout (seconds) HEALTH_CHECK_TIMEOUT_SECONDS=2 +# Maximum request body size in bytes (default: 10485760 = 10MB) +# Applies to POST /api/ai/summarize in both the handler and cache middleware. +# MAX_REQUEST_BODY_BYTES=10485760 # Metrics / Observability # Enable the gateway Prometheus endpoint (default: true) diff --git a/README.md b/README.md index eed1f7b7..ce7808ec 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,7 @@ Core local variables live in [.env.example](.env.example). Production placeholde | `METRICS_PATH` | Gateway | Gateway metrics path. Default `/metrics`; values without a leading slash are normalized. | | `ALLOWED_ORIGINS` | Gateway | Comma-separated CORS origins, no paths or query strings. | | `TRUSTED_PROXIES` | Gateway | Comma-separated trusted proxy CIDRs for production IP handling. | +| `MAX_REQUEST_BODY_BYTES` | Gateway | Maximum request body size in bytes for `POST /api/ai/summarize`. Default `10485760` (10 MB). | | `NEXT_PUBLIC_GATEWAY_URL` | Web | Gateway base URL. Browser fetches `/api/ai/summarize` and `/api/receipts/:id` here. | | `NEXT_PUBLIC_EXPECTED_CHAIN_ID` | Web | Chain ID the wallet widget targets. Must match the gateway's `CHAIN_ID`. Default `84532`. | | `NEXT_PUBLIC_EXPECTED_CHAIN_NAME` | Web | Display name paired with the chain ID — used by the wallet widget's "Switch to " button, hero headline, stat bar, and page title. Must be set when `CHAIN_ID` is overridden (e.g. `Base` for `8453`). | diff --git a/gateway/cache.go b/gateway/cache.go index 031d1723..7eaa13dd 100644 --- a/gateway/cache.go +++ b/gateway/cache.go @@ -46,11 +46,11 @@ func CacheMiddleware() gin.HandlerFunc { // Read request body to generate cache key // Check Content-Length first to reject oversized requests immediately - const maxBodySize = 10 * 1024 * 1024 + maxBody := getMaxRequestBodySize() // ContentLength == -1 means unknown (chunked encoding or no header), proceed to MaxBytesReader - if c.Request.ContentLength > maxBodySize { + if c.Request.ContentLength > maxBody { c.Header("Connection", "close") - c.JSON(413, gin.H{"error": "Payload too large", "max_size": "10MB"}) + c.JSON(413, gin.H{"error": "Payload too large", "max_size": formatBytes(maxBody)}) c.Abort() return } @@ -58,14 +58,14 @@ func CacheMiddleware() gin.HandlerFunc { var requestBody []byte var err error if c.Request.Body != nil { - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, int64(maxBodySize)) + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBody) requestBody, err = io.ReadAll(c.Request.Body) if err != nil { // If body too large, MaxBytesReader returns error var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { c.Header("Connection", "close") - c.JSON(413, gin.H{"error": "Payload too large", "max_size": "10MB"}) + c.JSON(413, gin.H{"error": "Payload too large", "max_size": formatBytes(maxBody)}) c.Abort() return } diff --git a/gateway/config.go b/gateway/config.go index 9ff0ebee..7ca1c152 100644 --- a/gateway/config.go +++ b/gateway/config.go @@ -5,6 +5,7 @@ import ( "log" "net/url" "os" + "strconv" "strings" "time" ) @@ -93,3 +94,41 @@ func getVerifierTimeout() time.Duration { return getPositiveTimeout("VERIFIER_TI func getHealthCheckTimeout() time.Duration { return getPositiveTimeout("HEALTH_CHECK_TIMEOUT_SECONDS", 2) } + +// defaultMaxRequestBodyBytes is 10 MiB, the default limit applied when +// MAX_REQUEST_BODY_BYTES is unset or invalid. It matches the previous +// hardcoded constant in handleSummarize and CacheMiddleware. +const defaultMaxRequestBodyBytes int64 = 10 * 1024 * 1024 + +// getMaxRequestBodySize returns the configured maximum request body size in +// bytes from the MAX_REQUEST_BODY_BYTES environment variable. Returns the +// default (10 MiB) when unset, non-numeric, zero, or negative. +func getMaxRequestBodySize() int64 { + valStr := os.Getenv("MAX_REQUEST_BODY_BYTES") + if valStr == "" { + return defaultMaxRequestBodyBytes + } + val, err := strconv.ParseInt(valStr, 10, 64) + if err != nil || val <= 0 { + log.Printf("Warning: Invalid MAX_REQUEST_BODY_BYTES %q, using default %d", valStr, defaultMaxRequestBodyBytes) + return defaultMaxRequestBodyBytes + } + return val +} + +// formatBytes returns a human-readable byte size string (e.g. "10MB", "512KB") +// used in 413 Payload Too Large error messages so the limit is always accurate. +func formatBytes(b int64) string { + const ( + mb = 1024 * 1024 + kb = 1024 + ) + switch { + case b >= mb && b%mb == 0: + return fmt.Sprintf("%dMB", b/mb) + case b >= kb && b%kb == 0: + return fmt.Sprintf("%dKB", b/kb) + default: + return fmt.Sprintf("%d bytes", b) + } +} diff --git a/gateway/config_test.go b/gateway/config_test.go index 0023a357..41ddc63b 100644 --- a/gateway/config_test.go +++ b/gateway/config_test.go @@ -474,3 +474,85 @@ func TestGetReceiptTTL(t *testing.T) { func stringPtr(value string) *string { return &value } + +func TestGetMaxRequestBodySize(t *testing.T) { + t.Run("default when unset", func(t *testing.T) { + t.Setenv("MAX_REQUEST_BODY_BYTES", "") + got := getMaxRequestBodySize() + want := int64(10 * 1024 * 1024) + if got != want { + t.Fatalf("expected default %d, got %d", want, got) + } + }) + + t.Run("custom value", func(t *testing.T) { + t.Setenv("MAX_REQUEST_BODY_BYTES", "5242880") + got := getMaxRequestBodySize() + want := int64(5 * 1024 * 1024) + if got != want { + t.Fatalf("expected %d, got %d", want, got) + } + }) + + t.Run("zero falls back to default", func(t *testing.T) { + t.Setenv("MAX_REQUEST_BODY_BYTES", "0") + got := getMaxRequestBodySize() + want := int64(10 * 1024 * 1024) + if got != want { + t.Fatalf("expected default %d for zero, got %d", want, got) + } + }) + + t.Run("negative falls back to default", func(t *testing.T) { + t.Setenv("MAX_REQUEST_BODY_BYTES", "-100") + got := getMaxRequestBodySize() + want := int64(10 * 1024 * 1024) + if got != want { + t.Fatalf("expected default %d for negative, got %d", want, got) + } + }) + + t.Run("invalid string falls back to default", func(t *testing.T) { + t.Setenv("MAX_REQUEST_BODY_BYTES", "not-a-number") + got := getMaxRequestBodySize() + want := int64(10 * 1024 * 1024) + if got != want { + t.Fatalf("expected default %d for invalid string, got %d", want, got) + } + }) + + t.Run("small value 1024", func(t *testing.T) { + t.Setenv("MAX_REQUEST_BODY_BYTES", "1024") + got := getMaxRequestBodySize() + want := int64(1024) + if got != want { + t.Fatalf("expected %d, got %d", want, got) + } + }) +} + +func TestFormatBytes(t *testing.T) { + tests := []struct { + name string + input int64 + want string + }{ + {"10MB", 10 * 1024 * 1024, "10MB"}, + {"1MB", 1 * 1024 * 1024, "1MB"}, + {"5MB", 5 * 1024 * 1024, "5MB"}, + {"512KB", 512 * 1024, "512KB"}, + {"1KB", 1024, "1KB"}, + {"odd bytes", 1500, "1500 bytes"}, + {"zero bytes", 0, "0 bytes"}, + {"100MB", 100 * 1024 * 1024, "100MB"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatBytes(tt.input) + if got != tt.want { + t.Fatalf("formatBytes(%d) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + diff --git a/gateway/main.go b/gateway/main.go index 953c94d1..4122a421 100644 --- a/gateway/main.go +++ b/gateway/main.go @@ -465,14 +465,14 @@ func handleSummarize(c *gin.Context) { // Read body if not already available if requestBody == nil { - // Read body with limit (only if middleware didn't process it) - const maxBodySize = 10 * 1024 * 1024 - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, int64(maxBodySize)) + // Read body with configurable limit (only if middleware didn't process it) + maxBody := getMaxRequestBodySize() + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBody) requestBody, err = io.ReadAll(c.Request.Body) if err != nil { var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { - c.JSON(413, gin.H{"error": "Payload too large", "max_size": "10MB"}) + c.JSON(413, gin.H{"error": "Payload too large", "max_size": formatBytes(maxBody)}) } else { respondError(c, 500, "request_body_read_failed", err) }