Fix error propagation: surface API validation messages in the UI #29
@@ -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.");
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user