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