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