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

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