fix(ui): actionable 413/504/401 toasts + VPS nginx deploy template
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Has been cancelled
CI / Backend — Odoo HttpCase (pull_request) Has been cancelled

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:
Yamen Ahmad
2026-04-21 09:09:23 +04:00
parent d34180e107
commit 75ee0f1fe0
7 changed files with 279 additions and 29 deletions

View 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 210 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
View 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

View File

@@ -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";

View File

@@ -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 ||

View File

@@ -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

View File

@@ -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",
});
},
});

View File

@@ -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",
});
},
});