From 7908dad1817770b528224711b2b66b20f01851d5 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 09:41:24 +0300 Subject: [PATCH] Fix error propagation: surface API validation messages in the UI - UseJsonExceptionHandler now maps InvalidOperationException to 400 (was 500), so upstream business-rule rejections reach the browser as actionable messages. - CvMatcherController forwards Refit 4xx bodies from cv-matcher-api instead of swallowing them in a generic 502. - ErrorResponse.Score removed; CaptchaController puts the score in Detail. - Frontend extractApiError helper reads the server Error/error/title field for 4xx responses and falls back to a generic i18n string for 5xx / missing body. - All four failure handlers (CV upload, CV match, contact form, subscribe form) updated to use extractApiError with the correct rate-limit i18n key. Co-Authored-By: Claude Sonnet 4.6 --- Apis/api/Controllers/CaptchaController.cs | 2 +- Apis/api/Controllers/CvMatcherController.cs | 20 ++++++++++ Apis/common/Responses/ErrorResponse.cs | 9 ++++- Helpers/startup-helpers/StartupExtensions.cs | 25 ++++++++++++- web/wwwroot/js/main.js | 39 ++++++++++++++------ 5 files changed, 80 insertions(+), 15 deletions(-) diff --git a/Apis/api/Controllers/CaptchaController.cs b/Apis/api/Controllers/CaptchaController.cs index b050523..40e3647 100644 --- a/Apis/api/Controllers/CaptchaController.cs +++ b/Apis/api/Controllers/CaptchaController.cs @@ -70,7 +70,7 @@ namespace Api.Controllers { Error = "Captcha verification failed.", Code = "captcha_verification_failed", - Score = verdict.Score + Detail = verdict.Score.HasValue ? $"Score: {verdict.Score:0.00}" : null }); } diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index 2d7b80e..6e5af67 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -112,6 +112,16 @@ public sealed class CvMatcherController : ControllerBase _logger.LogWarning("CV upload proxy request was cancelled by the client."); return StatusCode(499, new ErrorResponse { Error = "Request cancelled.", Code = "request_cancelled" }); } + catch (Refit.ApiException apiEx) when ((int)apiEx.StatusCode < 500) + { + // Forward upstream 4xx errors (e.g. "File is too large", "Only PDF files supported") + // so the browser can display the actionable message rather than a generic 502. + var body = await apiEx.GetContentAsAsync(); + _logger.LogWarning("Upstream cv-matcher-api returned {Status} during CV upload: {Error}", + (int)apiEx.StatusCode, body?.Error); + return StatusCode((int)apiEx.StatusCode, + body ?? new ErrorResponse { Error = apiEx.Message, Code = "upstream_error" }); + } catch (Exception ex) { _logger.LogError(ex, "CV upload proxy request failed."); @@ -196,6 +206,16 @@ public sealed class CvMatcherController : ControllerBase _logger.LogWarning("Job match proxy request was cancelled by the client."); return StatusCode(499, new ErrorResponse { Error = "Request cancelled.", Code = "request_cancelled" }); } + catch (Refit.ApiException apiEx) when ((int)apiEx.StatusCode < 500) + { + // Forward upstream 4xx errors (e.g. "Could not extract enough job text", + // "Invalid job URL") so the browser can display the actionable message. + var body = await apiEx.GetContentAsAsync(); + _logger.LogWarning("Upstream cv-matcher-api returned {Status} during job match: {Error}", + (int)apiEx.StatusCode, body?.Error); + return StatusCode((int)apiEx.StatusCode, + body ?? new ErrorResponse { Error = apiEx.Message, Code = "upstream_error" }); + } catch (Exception ex) { _logger.LogError(ex, "Job match proxy request failed."); diff --git a/Apis/common/Responses/ErrorResponse.cs b/Apis/common/Responses/ErrorResponse.cs index a1262d8..253a382 100644 --- a/Apis/common/Responses/ErrorResponse.cs +++ b/Apis/common/Responses/ErrorResponse.cs @@ -1,9 +1,16 @@ namespace Common.Responses; +/// +/// Standard error body returned by all API endpoints on 4xx and 5xx responses. +/// public sealed class ErrorResponse { + /// Human-readable error message, safe to display directly to the end user for 4xx responses. public string Error { get; init; } = string.Empty; + + /// Machine-readable error code for programmatic handling (e.g. "captcha_verification_failed"). public string? Code { get; init; } + + /// Optional additional detail for debugging (not shown in UI). public string? Detail { get; init; } - public double? Score { get; init; } } diff --git a/Helpers/startup-helpers/StartupExtensions.cs b/Helpers/startup-helpers/StartupExtensions.cs index d72b74e..e5fbc86 100644 --- a/Helpers/startup-helpers/StartupExtensions.cs +++ b/Helpers/startup-helpers/StartupExtensions.cs @@ -191,14 +191,35 @@ public static class StartupExtensions { var feature = context.Features.Get(); var logger = context.RequestServices.GetRequiredService().CreateLogger(serviceName); + + context.Response.ContentType = "application/json"; + + // InvalidOperationException signals an intentional business-rule violation + // (e.g. "Could not extract enough job text"). Surface it as 400 with the + // original message so the caller can show it directly to the user. + if (feature?.Error is InvalidOperationException ioe) + { + logger.LogWarning(ioe, "Business rule violation in {Service}", serviceName); + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(new Common.Responses.ErrorResponse + { + Error = ioe.Message, + Code = "validation_error" + }); + return; + } + if (feature?.Error is not null) { logger.LogError(feature.Error, "Unhandled exception in {Service}", serviceName); } context.Response.StatusCode = StatusCodes.Status500InternalServerError; - context.Response.ContentType = "application/json"; - await context.Response.WriteAsJsonAsync(new { error = "Unexpected server error." }); + await context.Response.WriteAsJsonAsync(new Common.Responses.ErrorResponse + { + Error = "Unexpected server error.", + Code = "internal_error" + }); }); }); } diff --git a/web/wwwroot/js/main.js b/web/wwwroot/js/main.js index 46839eb..e40a27c 100644 --- a/web/wwwroot/js/main.js +++ b/web/wwwroot/js/main.js @@ -403,6 +403,21 @@ } } + /** + * Extracts a user-facing error message from a failed API response body. + * For 4xx responses the server's error field is shown directly (it is intentional user feedback). + * For 5xx or missing body a generic i18n fallback is returned instead. + * @param {object|null} body Parsed JSON response body, or null if unparseable. + * @param {number} status HTTP status code. + * @param {string} fallbackKey i18n key to use for 5xx / unknown errors. + * @param {string} [rateLimitKey] i18n key for 429 responses; defaults to 'form.rateLimited'. + */ + function extractApiError(body, status, fallbackKey, rateLimitKey) { + if (status === 429) return t(rateLimitKey || 'form.rateLimited'); + var msg = body && (body.error || body.Error || body.title); + return (status >= 400 && status < 500 && msg) ? msg : t(fallbackKey); + } + function submitMSG(valid, msg, severity) { var tone = valid ? 'text-success' : ('text-' + (severity || 'danger')); $('#msgSubmit').removeClass().addClass('form-message ' + tone).text(msg); @@ -479,8 +494,7 @@ if (resp && resp.ok === true) formSuccess(); else submitMSG(false, t('form.captchaFailed')); }).fail(function (jqXHR) { - var isRateLimited = jqXHR && jqXHR.status === 429; - submitMSG(false, isRateLimited ? t('form.rateLimited') : t('form.failed'), isRateLimited ? 'warning' : 'danger'); + submitMSG(false, extractApiError(jqXHR.responseJSON, jqXHR.status, 'form.failed'), jqXHR.status === 429 ? 'warning' : 'danger'); formError(); }).always(function () { loader.hide(); @@ -554,8 +568,11 @@ method: 'POST', body: formData }); - if (cvResponse.status === 429) throw new Error(t('cv.rateLimited')); - if (!cvResponse.ok) throw new Error(t('cv.cvFailed')); + if (!cvResponse.ok) { + var cvErrBody = null; + try { cvErrBody = await cvResponse.json(); } catch (_) {} + throw new Error(extractApiError(cvErrBody, cvResponse.status, 'cv.cvFailed', 'cv.rateLimited')); + } var cvData = await cvResponse.json(); // Before calling match, obtain a fresh captcha token for the match action if (!(window.grecaptcha && reCaptchaSiteKey)) { @@ -586,8 +603,11 @@ language: currentLang() }) }); - if (matchResponse.status === 429) throw new Error(t('cv.rateLimited')); - if (!matchResponse.ok) throw new Error(t('cv.matchFailed')); + if (!matchResponse.ok) { + var matchErrBody = null; + try { matchErrBody = await matchResponse.json(); } catch (_) {} + throw new Error(extractApiError(matchErrBody, matchResponse.status, 'cv.matchFailed', 'cv.rateLimited')); + } var match = await matchResponse.json(); renderMatchResult(match); $msg.removeClass().addClass('form-message text-success').text(t('cv.completed')); @@ -652,11 +672,8 @@ setMsg('danger', 'form.captchaFailed'); } }).fail(function (jqXHR) { - if (jqXHR && jqXHR.status === 429) { - setMsg('warning', 'form.rateLimited'); - } else { - setMsg('danger', 'subscribe.failed'); - } + $msg.removeClass().addClass('form-message text-' + (jqXHR.status === 429 ? 'warning' : 'danger')) + .text(extractApiError(jqXHR.responseJSON, jqXHR.status, 'subscribe.failed')); }).always(function () { $loader.hide(); $button.prop('disabled', false); -- 2.52.0