Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ RATE_LIMIT_VERIFIED_RPM=120
# Cleanup interval for stale buckets (seconds)
RATE_LIMIT_CLEANUP_INTERVAL=300

# Request Body Size Configuration
# Maximum request body size in MB (default: 10)
MAX_REQUEST_BODY_MB=10

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 Document MAX_REQUEST_BODY_MB in config references

This introduces a new operator-facing gateway setting, but only .env.example mentions it; the main README configuration table, gateway/README.md optional-variable table, and .env.production.example still omit it. In production/setup contexts users will not discover the new limit knob or know its default/unit, so please keep those config references aligned with this new env var.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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


# Request Timeout Configuration
# Global request timeout (seconds)
REQUEST_TIMEOUT_SECONDS=60
Expand Down
7 changes: 7 additions & 0 deletions gateway/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,10 @@ func getVerifierTimeout() time.Duration { return getPositiveTimeout("VERIFIER_TI
func getHealthCheckTimeout() time.Duration {
return getPositiveTimeout("HEALTH_CHECK_TIMEOUT_SECONDS", 2)
}

// getMaxBodySize returns the maximum request body size in bytes, configured via
// the MAX_REQUEST_BODY_MB environment variable. Defaults to 10MB.
func getMaxBodySize() int64 {
mb := getEnvAsInt("MAX_REQUEST_BODY_MB", 10)
Comment thread
AnkanMisra marked this conversation as resolved.
Outdated
return int64(mb) * 1024 * 1024
Comment thread
AnkanMisra marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
6 changes: 3 additions & 3 deletions gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -416,13 +416,13 @@ 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))
maxSize := getMaxBodySize()
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
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": fmt.Sprintf("%dMB", maxSize/1024/1024)})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
respondError(c, 500, "request_body_read_failed", err)
}
Expand Down
6 changes: 4 additions & 2 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "tsc --noEmit"
"test": "tsc --noEmit",
"test:unit": "vitest run"
},
"dependencies": {
"@mdx-js/loader": "^3.1.1",
Expand All @@ -32,6 +33,7 @@
"eslint": "^9",
"eslint-config-next": "16.1.1",
"tailwindcss": "^4",
"typescript": "5.9.3"
"typescript": "5.9.3",
"vitest": "^3.1.2"
Comment thread
AnkanMisra marked this conversation as resolved.
Outdated
}
}
230 changes: 230 additions & 0 deletions web/src/lib/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { describe, it, expect } from "vitest";
import { classifyError } from "./errors";

// ── HTTP status + body based classification ──────────

describe("classifyError via HTTP context", () => {
it("classifies 400 + chain_id_mismatch as wrong-chain", () => {
const e = classifyError(null, {
status: 400,
bodyText: '{"error":"chain_id_mismatch"}',
});
expect(e.kind).toBe("wrong-chain");
expect(e.title).toBe("Wrong network");
expect(e.recoverable).toBe(true);
});

it("classifies 400 + invalid_timestamp as expired", () => {
const e = classifyError(null, {
status: 400,
bodyText: '{"error":"invalid_timestamp"}',
});
expect(e.kind).toBe("expired");
expect(e.title).toBe("Payment context expired");
});

it("classifies 400 + unknown error as invalid-signature", () => {
const e = classifyError(null, {
status: 400,
bodyText: '{"error":"something_else"}',
});
expect(e.kind).toBe("invalid-signature");
});

it("classifies 402 as expired", () => {
const e = classifyError(null, { status: 402 });
expect(e.kind).toBe("expired");
});

it("classifies 403 as invalid-signature", () => {
const e = classifyError(null, {
status: 403,
bodyText: '{"error":"invalid_signature"}',
});
expect(e.kind).toBe("invalid-signature");
expect(e.title).toBe("Signature was rejected");
});

it("classifies 408 as ai-timeout", () => {
const e = classifyError(null, { status: 408 });
expect(e.kind).toBe("ai-timeout");
});

it("classifies 409 + nonce_already_used as duplicate-nonce", () => {
const e = classifyError(null, {
status: 409,
bodyText: '{"error":"nonce_already_used"}',
});
expect(e.kind).toBe("duplicate-nonce");
expect(e.title).toBe("Signature already used");
expect(e.message).toContain("already submitted");
});

it("classifies 409 + other body as invalid-signature", () => {
const e = classifyError(null, {
status: 409,
bodyText: '{"error":"conflict"}',
});
expect(e.kind).toBe("invalid-signature");
});

it("classifies 429 as rate-limited", () => {
const e = classifyError(null, { status: 429 });
expect(e.kind).toBe("rate-limited");
expect(e.title).toBe("Rate limited");
});

it("classifies 502 + verification_unavailable as verifier-unavailable", () => {
const e = classifyError(null, {
status: 502,
bodyText: '{"error":"verification_unavailable"}',
});
expect(e.kind).toBe("verifier-unavailable");
expect(e.title).toBe("Verifier unavailable");
});

it("classifies 502 + upstream_unavailable as ai-unavailable", () => {
const e = classifyError(null, {
status: 502,
bodyText: '{"error":"upstream_unavailable"}',
});
expect(e.kind).toBe("ai-unavailable");
expect(e.title).toBe("AI provider unavailable");
});

it("classifies 504 + verifier_timeout as verifier-timeout", () => {
const e = classifyError(null, {
status: 504,
bodyText: '{"error":"verifier_timeout"}',
});
expect(e.kind).toBe("verifier-timeout");
expect(e.title).toBe("Verifier timed out");
});

it("classifies 504 + upstream_timeout as ai-timeout", () => {
const e = classifyError(null, {
status: 504,
bodyText: '{"error":"upstream_timeout"}',
});
expect(e.kind).toBe("ai-timeout");
expect(e.title).toBe("AI provider timed out");
});

it("classifies 5xx unknown as ai-unavailable", () => {
const e = classifyError(null, { status: 503 });
expect(e.kind).toBe("ai-unavailable");
});

it("classifies unknown status as unknown", () => {
const e = classifyError(null, { status: 418 });
expect(e.kind).toBe("unknown");
});
});

// ── Error object based classification ───────────────

describe("classifyError via thrown Error", () => {
it("classifies user rejection by code 4001", () => {
const e = classifyError({ code: 4001, message: "User rejected" });
expect(e.kind).toBe("user-rejected");
expect(e.title).toBe("You cancelled the signature");
expect(e.recoverable).toBe(true);
});

it("classifies user rejection by ACTION_REJECTED", () => {
const e = classifyError({ code: "ACTION_REJECTED", message: "MetaMask rejection" });
expect(e.kind).toBe("user-rejected");
});

it("classifies user rejection by message text", () => {
const e = classifyError(new Error("user rejected the request"));
expect(e.kind).toBe("user-rejected");
});

it("classifies wrong chain by message text", () => {
const e = classifyError(new Error("Wrong network. Please switch to Base Sepolia"));
expect(e.kind).toBe("wrong-chain");
});

it("classifies wrong chain by 'did not switch' message", () => {
const e = classifyError(new Error("Wallet did not switch to chain 84532"));
expect(e.kind).toBe("wrong-chain");
});

it("classifies missing wallet", () => {
const e = classifyError(new Error("No crypto wallet found"));
expect(e.kind).toBe("no-wallet");
expect(e.recoverable).toBe(false);
});

it("classifies network fetch error", () => {
const e = classifyError(new TypeError("Failed to fetch"));
expect(e.kind).toBe("network");
});

it("classifies network error by message", () => {
const e = classifyError(new Error("NetworkError: connection refused"));
expect(e.kind).toBe("network");
});

it("classifies unknown error as unknown", () => {
const e = classifyError(new Error("Something unexpected happened"));
expect(e.kind).toBe("unknown");
expect(e.title).toBe("Something broke");
});

it("handles non-object err gracefully", () => {
const e = classifyError("string error");
expect(e.kind).toBe("unknown");
});

it("handles null err gracefully", () => {
const e = classifyError(null);
expect(e.kind).toBe("unknown");
});
});

// ── Detail sanitization ─────────────────────────────

describe("sanitizeDetail via classified errors", () => {
it("extracts clean error code from gateway JSON", () => {
const e = classifyError(null, {
status: 403,
bodyText: '{"error":"invalid_signature","correlation_id":"rc_abc123"}',
});
expect(e.detail).toBe("[invalid_signature] correlation_id=rc_abc123");
});

it("extracts error code without correlation_id", () => {
const e = classifyError(null, {
status: 409,
bodyText: '{"error":"nonce_already_used"}',
});
expect(e.detail).toBe("[nonce_already_used]");
});

it("passes through non-JSON body text unchanged", () => {
const e = classifyError(null, {
status: 429,
bodyText: "rate limit exceeded",
});
expect(e.detail).toBe("rate limit exceeded");
});

it("truncates long non-JSON body", () => {
const long = "x".repeat(300);
const e = classifyError(null, { status: 500, bodyText: long });
expect(e.detail).toBe("x".repeat(200) + "...");
expect(e.detail!.length).toBe(203);
});

it("sets undefined detail when no bodyText", () => {
const e = classifyError(null, { status: 429 });
expect(e.detail).toBeUndefined();
});

it("uses error.message as detail for thrown errors", () => {
const e = classifyError(new Error("user rejected the request"));
expect(e.detail).toBe("user rejected the request");
});
});
24 changes: 22 additions & 2 deletions web/src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type ErrorKind =
| "wrong-chain"
| "user-rejected"
| "expired"
| "duplicate-nonce"
| "invalid-signature"
| "rate-limited"
| "ai-timeout"
Expand Down Expand Up @@ -43,6 +44,10 @@ const COPY: Record<ErrorKind, { title: string; message: string }> = {
title: "Payment context expired",
message: "The signed challenge took too long to return. Retry to get a fresh one.",
},
"duplicate-nonce": {
title: "Signature already used",
message: "This payment context was already submitted. Each challenge can only be used once. Retry to get a fresh one.",
},
"invalid-signature": {
title: "Signature was rejected",
message:
Expand Down Expand Up @@ -85,11 +90,26 @@ function build(kind: ErrorKind, detail?: string): ClassifiedError {
return {
kind,
...COPY[kind],
detail,
detail: sanitizeDetail(detail),
recoverable: kind !== "no-wallet",
};
}

function sanitizeDetail(raw: string | undefined): string | undefined {
if (!raw) return undefined;
try {
const parsed = JSON.parse(raw);
if (parsed.error && typeof parsed.error === "string") {
const code = parsed.error;
if (parsed.correlation_id) {
return `[${code}] correlation_id=${parsed.correlation_id}`;
}
return `[${code}]`;
}
} catch {}
return raw.length > 200 ? raw.slice(0, 200) + "..." : raw;
}

// Maps gateway HTTP status + sanitized error body to a user-facing kind.
// The gateway's public error codes (gateway/errors.go) are stable strings
// like "chain_id_mismatch", "invalid_timestamp", "verifier_timeout",
Expand All @@ -108,7 +128,7 @@ function statusToKind(status: number, body: string): ErrorKind {
return body.includes("verifier_timeout") ? "verifier-timeout" : "ai-timeout";
}
if (status === 409) {
return body.includes("nonce_already_used") ? "expired" : "invalid-signature";
return body.includes("nonce_already_used") ? "duplicate-nonce" : "invalid-signature";
}
if (status === 429) return "rate-limited";
if (status === 502) {
Expand Down