btcmaps v1: timeout vs caller-cancel split + canonical-case type/subType on write

Addresses thgO-O review id 4274558448 (PR #224) items #2 and #3.

#2 OperationCanceledException vs HttpClient.Timeout: the prior
catch-when filter excluded OCE wholesale to avoid swallowing client
disconnects, but HttpClient.Timeout surfaces as TaskCanceledException
(inherits from OCE) so an upstream GitHub timeout slipped through and
fell out as a 500 instead of the intended 502. Now split into three
arms: caller-cancel rethrows (info-log + drop the connection),
upstream-timeout returns 504 with discriminant directory-upstream-
timeout, anything else still 502 directory-upstream-failed.

#3 type/subType case normalization on write: ValidTypes +
ValidMerchantSubTypes match OrdinalIgnoreCase at validation, but the
write at BuildMerchantEntry preserved the user-submitted casing. A
submission of "Merchants" / "Books" would pass validation and land
non-canonical in merchants.json. Normalize to ToLowerInvariant on
write so the file stays in the lowercase convention. Country is
already validated against a case-sensitive Ordinal set so the
validator rejects non-uppercase before reaching the writer; no write-
path change needed there.
This commit is contained in:
r1ckstardev 2026-05-13 12:42:50 +00:00
parent b84538f2c2
commit 170d56d6d8
2 changed files with 30 additions and 3 deletions

View File

@ -49,7 +49,28 @@ public sealed class BtcMapsController(
correlationId
});
}
catch (Exception ex) when (ex is not OperationCanceledException)
catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested)
{
// Caller cancelled (client disconnect, request abort). Rethrow so
// the pipeline drops the connection without producing a response
// body the client will never read.
logger.LogInformation(ex, "BTCMaps directory submission cancelled by caller (correlationId={CorrelationId})", correlationId);
throw;
}
catch (OperationCanceledException ex)
{
// OCE without caller cancellation = HttpClient.Timeout surfacing as
// TaskCanceledException. Treat as an upstream timeout, distinct
// from a generic 502 so ops + the plugin client can tell them apart.
logger.LogError(ex, "BTCMaps directory submission timed out upstream (correlationId={CorrelationId}) for {Name} ({Url})",
correlationId, request.Name, request.Url);
return StatusCode(StatusCodes.Status504GatewayTimeout, new
{
error = "directory-upstream-timeout",
correlationId
});
}
catch (Exception ex)
{
logger.LogError(ex, "BTCMaps directory submission failed (correlationId={CorrelationId}) for {Name} ({Url})",
correlationId, request.Name, request.Url);

View File

@ -273,9 +273,15 @@ public sealed class BtcMapsService
w.WriteString("name", request.Name!.Trim());
w.WriteString("url", request.Url!.Trim());
w.WriteString("description", request.Description!.Trim());
w.WriteString("type", request.Type!.Trim());
// type + subType are validated case-insensitively above
// (ValidTypes / ValidMerchantSubTypes use OrdinalIgnoreCase) but
// the upstream merchants.json convention is lowercase. Normalize
// on write so a submission of "Merchants" / "Books" lands as
// "merchants" / "books" in the file. Country uses a case-sensitive
// Ordinal set so the validator already rejects non-uppercase.
w.WriteString("type", request.Type!.Trim().ToLowerInvariant());
if (!string.IsNullOrWhiteSpace(request.SubType))
w.WriteString("subType", request.SubType.Trim());
w.WriteString("subType", request.SubType.Trim().ToLowerInvariant());
if (!string.IsNullOrWhiteSpace(request.Country))
w.WriteString("country", request.Country.Trim());
if (!string.IsNullOrWhiteSpace(request.Twitter))