Fix error propagation: surface API validation messages in the UI #29

Merged
gelu merged 1 commits from fix/error-propagation-28 into main 2026-05-28 06:43:25 +00:00
5 changed files with 80 additions and 15 deletions
+1 -1
View File
@@ -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
});
}
@@ -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<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)
{
_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<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)
{
_logger.LogError(ex, "Job match proxy request failed.");
+8 -1
View File
@@ -1,9 +1,16 @@
namespace Common.Responses;
/// <summary>
/// Standard error body returned by all API endpoints on 4xx and 5xx responses.
/// </summary>
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;
/// <summary>Machine-readable error code for programmatic handling (e.g. <c>"captcha_verification_failed"</c>).</summary>
public string? Code { get; init; }
/// <summary>Optional additional detail for debugging (not shown in UI).</summary>
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 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)
{
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"
});
});
});
}
+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) {
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);