fix(ui): actionable 413/504/401 toasts + VPS nginx deploy template
Production QA reported "Submit failed 413" on /generation and plain "Upload failed" (no detail) on resource upload. Root cause is the outer reverse-proxy nginx on the VPS — still on default `client_max_body_size 1m` — rejecting requests before they reach Odoo. The inner docker-nginx and Odoo limits were already raised in earlier commits; only the VPS proxy was left unpatched. UI side - Add `describeApiError(err, fallback, context)` helper in lib/api-client.ts. Maps common HTTP codes to human-readable, actionable toast descriptions (413 → "ask ops to raise nginx client_max_body_size", 504 → "server took too long, retry", 502 → "Odoo is restarting", 401 → "sign in again", 4xx/5xx → server error body + status code). - Use it in GenerationPage submit, ResourceManager upload, TeacherLibrary upload, CustomExamCreate save/publish. Replaces four slightly-different ad-hoc mappings with one source of truth. Deploy side - Add deploy/nginx-vps.conf.example: full TLS reverse-proxy template with the body/timeout knobs that must match Odoo's limit_request (128 MB) and limit_time_real (900 s). - Add docs/DEPLOY_413_504_FIX.md: step-by-step remediation guide for the team lead covering the 3 layers that can block large bodies (outer nginx, Cloudflare, Odoo worker) and how to verify each. Made-with: Cursor
This commit is contained in:
85
deploy/nginx-vps.conf.example
Normal file
85
deploy/nginx-vps.conf.example
Normal file
@@ -0,0 +1,85 @@
|
||||
# EnCoach — example VPS reverse-proxy config
|
||||
#
|
||||
# Sits in front of the docker-compose stack (frontend:80 + backend:8069).
|
||||
# Copy to /etc/nginx/sites-available/encoach, edit server_name / TLS paths,
|
||||
# symlink to sites-enabled, then `nginx -t && systemctl reload nginx`.
|
||||
#
|
||||
# The inner frontend container already raises its own nginx limits
|
||||
# (see frontend/Dockerfile). If THIS outer proxy isn't raised too, the
|
||||
# outer layer returns 413/504 before traffic ever reaches Odoo, and users
|
||||
# see "Submit failed 413" or "Upload failed" with no server-side log entry.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# HTTP → HTTPS redirect (remove if you're not terminating TLS here)
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name encoach.example.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Main TLS vhost
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name encoach.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/encoach.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/encoach.example.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# ── CRITICAL ────────────────────────────────────────────────
|
||||
# Default nginx caps request bodies at 1 MB, which triggers
|
||||
# HTTP 413 on:
|
||||
# * /api/resources (PDF / audio / image uploads)
|
||||
# * /api/exam/generation/submit (full module payload — can
|
||||
# easily be 2–10 MB when all tasks are filled)
|
||||
# * /api/approval-requests (attachments on approval flow)
|
||||
# Match this value to Odoo's `limit_request` (128 MB).
|
||||
client_max_body_size 128m;
|
||||
client_body_buffer_size 1m;
|
||||
client_body_timeout 900s;
|
||||
large_client_header_buffers 4 16k;
|
||||
|
||||
# ── Static / SPA served by the frontend container ───────────
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080; # docker-compose: frontend:80
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# ── Odoo API: AI calls can take minutes, needs long timeouts ─
|
||||
# Must exceed Odoo's limit_time_real (900s in odoo-docker.conf).
|
||||
# proxy_request_buffering off streams the body to Odoo instead of
|
||||
# buffering it entirely on disk → less chance of hitting the 413
|
||||
# when uploading big resources.
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8069/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_read_timeout 900s;
|
||||
proxy_send_timeout 900s;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off; # stream AI responses to the client
|
||||
}
|
||||
|
||||
# ── Odoo web client + longpolling (optional, for /web/* URLs) ─
|
||||
location /longpolling/ {
|
||||
proxy_pass http://127.0.0.1:8069/longpolling/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# Access / error logs — keep them separate so 413s are easy to spot
|
||||
access_log /var/log/nginx/encoach.access.log;
|
||||
error_log /var/log/nginx/encoach.error.log warn;
|
||||
}
|
||||
105
docs/DEPLOY_413_504_FIX.md
Normal file
105
docs/DEPLOY_413_504_FIX.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Why "Submit failed 413" and "Upload failed" still happen in production
|
||||
|
||||
QA keeps reporting that the following two actions fail on the deployed VPS
|
||||
while working locally:
|
||||
|
||||
- **Resource Manager → Upload**: toast shows "Upload failed" (no details).
|
||||
- **Generation → Submit module as exam for approval**: toast shows
|
||||
"Submit failed — 413".
|
||||
|
||||
Both are the same root cause: the **outer nginx reverse proxy** on the VPS
|
||||
(the one terminating TLS in front of the docker stack) is still using its
|
||||
default `client_max_body_size 1m` and short proxy timeouts. We already
|
||||
raised the inner limits in:
|
||||
|
||||
- `odoo.conf` / `odoo-docker.conf` → `limit_request 134217728` (128 MB) +
|
||||
`limit_time_real 900s`
|
||||
- `frontend/Dockerfile` → `client_max_body_size 128m` +
|
||||
`proxy_read_timeout 900s` + `proxy_request_buffering off`
|
||||
|
||||
But the **outer** nginx wasn't changed, so it still rejects the request
|
||||
(413) or times the proxied connection (504) before traffic ever reaches
|
||||
Odoo. Because the refusal happens at the proxy, nothing shows up in the
|
||||
Odoo logs — only in `/var/log/nginx/*.error.log`.
|
||||
|
||||
## Fix on the VPS (one-time, 2 minutes)
|
||||
|
||||
1. SSH to the VPS and back up the existing config:
|
||||
|
||||
```bash
|
||||
sudo cp /etc/nginx/sites-available/encoach /etc/nginx/sites-available/encoach.bak
|
||||
```
|
||||
|
||||
2. Copy the template from the repo and edit the `server_name` + TLS
|
||||
cert paths to match the real hostnames:
|
||||
|
||||
```bash
|
||||
sudo cp deploy/nginx-vps.conf.example /etc/nginx/sites-available/encoach
|
||||
sudo ln -sf /etc/nginx/sites-available/encoach /etc/nginx/sites-enabled/encoach
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
The critical lines added are:
|
||||
|
||||
```nginx
|
||||
client_max_body_size 128m; # matches Odoo limit_request
|
||||
client_body_timeout 900s;
|
||||
proxy_read_timeout 900s; # matches Odoo limit_time_real
|
||||
proxy_send_timeout 900s;
|
||||
proxy_request_buffering off; # stream uploads, don't buffer 100% on disk
|
||||
```
|
||||
|
||||
3. Smoke-test:
|
||||
|
||||
```bash
|
||||
# Upload a ~30 MB PDF through /api/resources — should return 200, not 413.
|
||||
# Submit a module from /generation — should return 201, not 413.
|
||||
tail -f /var/log/nginx/encoach.error.log # watch for "client intended to send too large body"
|
||||
tail -f /var/log/odoo/odoo-server.log # watch for the actual request hitting Odoo
|
||||
```
|
||||
|
||||
## If the 413 persists after the nginx change
|
||||
|
||||
The body is getting rejected by something upstream. Check in order:
|
||||
|
||||
1. **Cloudflare / WAF** — Cloudflare's default free-plan body cap is 100 MB
|
||||
for authenticated endpoints and as low as 1 MB on some enterprise
|
||||
rules. In the dashboard → Rules → Transform / Firewall, look for
|
||||
`request.body.size` restrictions.
|
||||
2. **Odoo worker (`limit_request`)** — verify
|
||||
`grep limit_request /etc/odoo/odoo.conf` shows at least `134217728`.
|
||||
If the config file was baked into the docker image and never rebuilt,
|
||||
`docker compose build backend && docker compose up -d backend`.
|
||||
3. **`ModSecurity` / CRL** — some VPS providers ship ModSecurity enabled
|
||||
by default (`SecRequestBodyLimit`, default 13 MB).
|
||||
|
||||
## If the 504 persists
|
||||
|
||||
504 = the upstream didn't respond in time.
|
||||
|
||||
- Confirm `proxy_read_timeout 900s` is actually in the running config
|
||||
(`nginx -T | grep proxy_read_timeout`).
|
||||
- Confirm `limit_time_real` in `odoo.conf` is at least 900 (default is
|
||||
120; a single AI generation with 4 modules can exceed 3 minutes).
|
||||
- For first-time submissions, Odoo may need to warm up its AI clients.
|
||||
Retry once after a minute.
|
||||
|
||||
## What the UI now tells the user
|
||||
|
||||
The frontend toast has been upgraded (see `lib/api-client.ts::describeApiError`).
|
||||
Users will now see, for example:
|
||||
|
||||
- **413**: _"The module is too large for the server to accept (HTTP 413).
|
||||
Try a smaller file / fewer tasks, or ask an admin to raise the nginx
|
||||
client_max_body_size and Odoo limit_request."_
|
||||
- **504**: _"The server took too long to respond (HTTP 504). The module
|
||||
may still be processing — wait a minute and reload before retrying."_
|
||||
- **502**: _"The backend is unreachable (HTTP 502). Odoo may be
|
||||
restarting — try again in a moment."_
|
||||
- **401**: _"Your session expired — please sign in again."_
|
||||
|
||||
This applies to:
|
||||
- Resource upload (admin + teacher library)
|
||||
- Exam generation submit
|
||||
- Custom-exam publish / save draft
|
||||
@@ -40,6 +40,64 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn an arbitrary thrown value from an API mutation into a human-readable,
|
||||
* actionable toast description. Deployment QA kept reporting generic
|
||||
* "Submit failed" / "Upload failed" toasts — this helper surfaces the HTTP
|
||||
* status and, when we recognise the code, a hint about what to do next
|
||||
* (payload too big → ask ops to raise nginx, timeout → retry, etc.).
|
||||
*
|
||||
* @param err the error thrown by an `api.*` call (usually an ApiError)
|
||||
* @param fallback text to show if we can't classify the error
|
||||
* @param context short context word used in the 413/504 hints
|
||||
* ("file", "request", "payload" …)
|
||||
*/
|
||||
export function describeApiError(
|
||||
err: unknown,
|
||||
fallback = "Something went wrong",
|
||||
context = "request",
|
||||
): string {
|
||||
const e = err as { status?: number; message?: string; data?: unknown } | null;
|
||||
const status = e?.status;
|
||||
// Try to extract a server-side error body even if the generic ApiError
|
||||
// message wasn't populated (nginx 413/504 respond with an HTML page so
|
||||
// `data.error` is empty and `message` looks like "413 ").
|
||||
const serverMsg =
|
||||
e?.data && typeof e.data === "object" && "error" in (e.data as object)
|
||||
? String((e.data as { error: unknown }).error || "").trim()
|
||||
: "";
|
||||
|
||||
if (status === 413) {
|
||||
return `The ${context} is too large for the server to accept (HTTP 413). Try a smaller file / fewer tasks, or ask an admin to raise the nginx \`client_max_body_size\` and Odoo \`limit_request\`.`;
|
||||
}
|
||||
if (status === 504) {
|
||||
return `The server took too long to respond (HTTP 504). The ${context} may still be processing — wait a minute and reload before retrying.`;
|
||||
}
|
||||
if (status === 502) {
|
||||
return "The backend is unreachable (HTTP 502). Odoo may be restarting — try again in a moment.";
|
||||
}
|
||||
if (status === 401) {
|
||||
return "Your session expired — please sign in again.";
|
||||
}
|
||||
if (status === 403) {
|
||||
return serverMsg || "You don't have permission to do that (HTTP 403).";
|
||||
}
|
||||
if (status === 404) {
|
||||
return serverMsg || "The requested item no longer exists (HTTP 404).";
|
||||
}
|
||||
if (status === 422 || status === 400) {
|
||||
return serverMsg || `The server rejected the ${context} as invalid (HTTP ${status}).`;
|
||||
}
|
||||
if (status && status >= 500) {
|
||||
return `${serverMsg || "The server returned an error"} (HTTP ${status}). Check the Odoo logs for a stack trace.`;
|
||||
}
|
||||
|
||||
// Network failure / fetch aborted / CORS — ApiError not thrown at all.
|
||||
if (!status && e?.message) return e.message;
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const ACCESS_KEY = "encoach_token";
|
||||
const REFRESH_KEY = "encoach_refresh_token";
|
||||
const EXP_KEY = "encoach_token_exp";
|
||||
|
||||
@@ -61,7 +61,7 @@ import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
import { generationService } from "@/services/generation.service";
|
||||
import { mediaService, type Avatar } from "@/services/media.service";
|
||||
import { examsService } from "@/services/exams.service";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { api, describeApiError } from "@/lib/api-client";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { ExamStructure, ExamStructureConfig } from "@/types";
|
||||
|
||||
@@ -807,7 +807,12 @@ export default function GenerationPage() {
|
||||
description: `Exam #${res.exam_id} created with status "${res.status}". ${res.status === "published" ? "The exam is now live." : "Pending approval."}`,
|
||||
duration: 8000,
|
||||
}),
|
||||
onError: (err: Error) => toast({ variant: "destructive", title: "Submit failed", description: err.message }),
|
||||
onError: (err) =>
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Submit failed",
|
||||
description: describeApiError(err, "Submit failed", "module"),
|
||||
}),
|
||||
});
|
||||
|
||||
const anyGenerating = generatePassageMut.isPending || generateExercisesMut.isPending ||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useCreateCustomExam, useSaveAsTemplate } from "@/hooks/queries/useExamTemplates";
|
||||
import { describeApiError } from "@/lib/api-client";
|
||||
import { useSubjects } from "@/hooks/queries/useAdaptive";
|
||||
import type { CustomExamCreateRequest, CustomScoringMethod } from "@/types";
|
||||
import { toast } from "sonner";
|
||||
@@ -139,14 +140,11 @@ export default function CustomExamCreate() {
|
||||
return raw?.id ?? raw?.exam_id ?? raw?.data?.id ?? raw?.data?.exam_id;
|
||||
};
|
||||
|
||||
const describeError = (err: unknown, fallback: string): string => {
|
||||
const e = err as { status?: number; message?: string };
|
||||
if (e?.status === 413) return "Payload too large (HTTP 413). Please reduce the number of questions per section and try again.";
|
||||
if (e?.status === 504) return "Server took too long to respond (HTTP 504). Please retry in a moment.";
|
||||
if (e?.status === 401) return "Session expired — please sign in again.";
|
||||
if (e?.message) return `${fallback}: ${e.message}`;
|
||||
return fallback;
|
||||
};
|
||||
// Thin wrapper around the shared helper so the existing call-sites below
|
||||
// keep working; the helper lives in api-client so every page gets the same
|
||||
// actionable 413/504/401 messaging.
|
||||
const describeError = (err: unknown, fallback: string): string =>
|
||||
describeApiError(err, fallback, "exam");
|
||||
|
||||
const onSaveDraft = async () => {
|
||||
// Validate everything before firing the request — previously the Save
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { resourcesService } from "@/services/resources.service";
|
||||
import { coursewareService } from "@/services/courseware.service";
|
||||
import { taxonomyService } from "@/services/taxonomy.service";
|
||||
import { describeApiError } from "@/lib/api-client";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { TaxonomyCascade } from "@/components/TaxonomyCascade";
|
||||
import type { Resource, ResourceTag } from "@/types";
|
||||
@@ -274,17 +275,16 @@ export default function ResourceManager() {
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (formData: FormData) => resourcesService.upload(formData),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Resource uploaded" }); setShowUpload(false); setUploadTagIds([]); setUploadSubjectId("none"); setUploadDomainId("none"); setUploadTopicIds([]); setUploadObjectiveIds([]); },
|
||||
onError: (err: unknown) => {
|
||||
// Surface status / server message so users can tell 413 (file too big)
|
||||
// from 401 (session expired) from 500 (server error) without opening
|
||||
// devtools. Default "Upload failed" hid real failures during QA.
|
||||
const e = err as { status?: number; message?: string };
|
||||
const status = e?.status;
|
||||
let description = e?.message || "Upload failed";
|
||||
if (status === 413) description = "File is too large for the server (HTTP 413). Ask an admin to raise nginx/odoo upload limits.";
|
||||
else if (status === 401) description = "Session expired — please sign in again.";
|
||||
else if (status === 504) description = "Server timed out while saving the file (HTTP 504). Try a smaller file or retry.";
|
||||
toast({ title: "Upload failed", description, variant: "destructive" });
|
||||
onError: (err) => {
|
||||
// describeApiError surfaces status code + actionable next step so QA /
|
||||
// ops can tell 413 (payload limit) from 504 (timeout) from 401 (session
|
||||
// expired) without opening DevTools. Default "Upload failed" was
|
||||
// reported as non-actionable during the Apr-2026 deployment.
|
||||
toast({
|
||||
title: "Upload failed",
|
||||
description: describeApiError(err, "Upload failed", "file"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { resourcesService } from "@/services/resources.service";
|
||||
import { describeApiError } from "@/lib/api-client";
|
||||
import { coursewareService } from "@/services/courseware.service";
|
||||
import { taxonomyService } from "@/services/taxonomy.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -116,14 +117,12 @@ export default function TeacherLibrary() {
|
||||
setUploadTopicIds([]);
|
||||
setUploadObjectiveIds([]);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const e = err as { status?: number; message?: string };
|
||||
const status = e?.status;
|
||||
let description = e?.message || "Upload failed";
|
||||
if (status === 413) description = "File is too large for the server (HTTP 413). Try a smaller file or ask an admin to raise the upload limit.";
|
||||
else if (status === 401) description = "Session expired — please sign in again.";
|
||||
else if (status === 504) description = "Server timed out while saving the file (HTTP 504). Try a smaller file or retry.";
|
||||
toast({ title: "Upload failed", description, variant: "destructive" });
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: "Upload failed",
|
||||
description: describeApiError(err, "Upload failed", "file"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user