Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pass MAX_REQUEST_BODY_BYTES through Compose

When users follow the documented Docker Compose flow and uncomment/set this value in the root .env, the gateway container still will not see it because the docker-compose.yml gateway environment list only passes selected variables and omits MAX_REQUEST_BODY_BYTES (checked docker-compose.yml lines 8-26). As a result Compose deployments keep using the 10MB default despite the new .env.example/README setting; add this variable to the gateway service environment so the advertised configuration works there too.

Useful? React with 👍 / 👎.


# Metrics / Observability
# Enable the gateway Prometheus endpoint (default: true)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Keep gateway-specific docs in sync

This new root README entry documents the knob, but the gateway service README still has its own environment table and does not mention MAX_REQUEST_BODY_BYTES (checked gateway/README.md around the config table). Contributors running or deploying only gateway/ from that service README will miss the new setting, so add the same variable there to keep the service docs aligned with the changed gateway behavior.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add the new gateway limit to production env docs

The production path points operators at .env.production.example and the Render env block in DEPLOY.md, but this change only documents MAX_REQUEST_BODY_BYTES in the local example/root README. In production deployments that use those templates, raising or lowering the summarize payload limit remains undiscoverable and the gateway silently keeps the 10MB default; add the variable to the production env template/deploy block as well.

Useful? React with 👍 / 👎.

| `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 <name>" button, hero headline, stat bar, and page title. Must be set when `CHAIN_ID` is overridden (e.g. `Base` for `8453`). |
Expand Down
10 changes: 5 additions & 5 deletions gateway/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,26 +46,26 @@ 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
}

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
}
Expand Down
39 changes: 39 additions & 0 deletions gateway/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"log"
"net/url"
"os"
"strconv"
"strings"
"time"
)
Expand Down Expand Up @@ -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)
}
}
82 changes: 82 additions & 0 deletions gateway/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Remove the non-gofmt EOF blank line

The repository instructions require Go changes to be gofmt-clean, but this added trailing blank line makes gofmt -l gateway/config_test.go report the file and git diff --check flags new blank line at EOF. Removing the extra EOF blank line keeps the committed test file formatter-clean and avoids formatting-check churn.

Useful? React with 👍 / 👎.

8 changes: 4 additions & 4 deletions gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading