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 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 09:41:24 +03:00
parent 2e1efc598b
commit 7908dad181
5 changed files with 80 additions and 15 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ namespace Api.Controllers
{ {
Error = "Captcha verification failed.", Error = "Captcha verification failed.",
Code = "captcha_verification_failed", Code = "captcha_verification_failed",
Score = verdict.Score Detail = verdict.Score.HasValue ? $"Score: {verdict.Score:0.00}" : null
}); });
} }
@@ -112,6 +112,16 @@ public sealed class CvMatcherController : ControllerBase
_logger.LogWarning("CV upload proxy request was cancelled by the client."); _logger.LogWarning("CV upload proxy request was cancelled by the client.");
return StatusCode(499, new ErrorResponse { Error = "Request cancelled.", Code = "request_cancelled" }); 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<ErrorResponse>();
_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) catch (Exception ex)
{ {
_logger.LogError(ex, "CV upload proxy request failed."); _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."); _logger.LogWarning("Job match proxy request was cancelled by the client.");
return StatusCode(499, new ErrorResponse { Error = "Request cancelled.", Code = "request_cancelled" }); 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<ErrorResponse>();
_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) catch (Exception ex)
{ {
_logger.LogError(ex, "Job match proxy request failed."); _logger.LogError(ex, "Job match proxy request failed.");
+8 -1
View File
@@ -1,9 +1,16 @@
namespace Common.Responses; namespace Common.Responses;
/// <summary>
/// Standard error body returned by all API endpoints on 4xx and 5xx responses.
/// </summary>
public sealed class ErrorResponse public sealed class ErrorResponse
{ {
/// <summary>Human-readable error message, safe to display directly to the end user for 4xx responses.</summary>
public string Error { get; init; } = string.Empty; public string Error { get; init; } = string.Empty;
/// <summary>Machine-readable error code for programmatic handling (e.g. <c>"captcha_verification_failed"</c>).</summary>
public string? Code { get; init; } public string? Code { get; init; }
/// <summary>Optional additional detail for debugging (not shown in UI).</summary>
public string? Detail { get; init; } public string? Detail { get; init; }
public double? Score { get; init; }
} }
+23 -2
View File
@@ -191,14 +191,35 @@ public static class StartupExtensions
{ {
var feature = context.Features.Get<IExceptionHandlerFeature>(); var feature = context.Features.Get<IExceptionHandlerFeature>();
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger(serviceName); var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().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) if (feature?.Error is not null)
{ {
logger.LogError(feature.Error, "Unhandled exception in {Service}", serviceName); logger.LogError(feature.Error, "Unhandled exception in {Service}", serviceName);
} }
context.Response.StatusCode = StatusCodes.Status500InternalServerError; context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json"; await context.Response.WriteAsJsonAsync(new Common.Responses.ErrorResponse
await context.Response.WriteAsJsonAsync(new { error = "Unexpected server error." }); {
Error = "Unexpected server error.",
Code = "internal_error"
});
}); });
}); });
} }
+28 -11
View File
@@ -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) { function submitMSG(valid, msg, severity) {
var tone = valid ? 'text-success' : ('text-' + (severity || 'danger')); var tone = valid ? 'text-success' : ('text-' + (severity || 'danger'));
$('#msgSubmit').removeClass().addClass('form-message ' + tone).text(msg); $('#msgSubmit').removeClass().addClass('form-message ' + tone).text(msg);
@@ -479,8 +494,7 @@
if (resp && resp.ok === true) formSuccess(); if (resp && resp.ok === true) formSuccess();
else submitMSG(false, t('form.captchaFailed')); else submitMSG(false, t('form.captchaFailed'));
}).fail(function (jqXHR) { }).fail(function (jqXHR) {
var isRateLimited = jqXHR && jqXHR.status === 429; submitMSG(false, extractApiError(jqXHR.responseJSON, jqXHR.status, 'form.failed'), jqXHR.status === 429 ? 'warning' : 'danger');
submitMSG(false, isRateLimited ? t('form.rateLimited') : t('form.failed'), isRateLimited ? 'warning' : 'danger');
formError(); formError();
}).always(function () { }).always(function () {
loader.hide(); loader.hide();
@@ -554,8 +568,11 @@
method: 'POST', method: 'POST',
body: formData body: formData
}); });
if (cvResponse.status === 429) throw new Error(t('cv.rateLimited')); if (!cvResponse.ok) {
if (!cvResponse.ok) throw new Error(t('cv.cvFailed')); 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(); var cvData = await cvResponse.json();
// Before calling match, obtain a fresh captcha token for the match action // Before calling match, obtain a fresh captcha token for the match action
if (!(window.grecaptcha && reCaptchaSiteKey)) { if (!(window.grecaptcha && reCaptchaSiteKey)) {
@@ -586,8 +603,11 @@
language: currentLang() language: currentLang()
}) })
}); });
if (matchResponse.status === 429) throw new Error(t('cv.rateLimited')); if (!matchResponse.ok) {
if (!matchResponse.ok) throw new Error(t('cv.matchFailed')); 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(); var match = await matchResponse.json();
renderMatchResult(match); renderMatchResult(match);
$msg.removeClass().addClass('form-message text-success').text(t('cv.completed')); $msg.removeClass().addClass('form-message text-success').text(t('cv.completed'));
@@ -652,11 +672,8 @@
setMsg('danger', 'form.captchaFailed'); setMsg('danger', 'form.captchaFailed');
} }
}).fail(function (jqXHR) { }).fail(function (jqXHR) {
if (jqXHR && jqXHR.status === 429) { $msg.removeClass().addClass('form-message text-' + (jqXHR.status === 429 ? 'warning' : 'danger'))
setMsg('warning', 'form.rateLimited'); .text(extractApiError(jqXHR.responseJSON, jqXHR.status, 'subscribe.failed'));
} else {
setMsg('danger', 'subscribe.failed');
}
}).always(function () { }).always(function () {
$loader.hide(); $loader.hide();
$button.prop('disabled', false); $button.prop('disabled', false);