[AZ-359] Refactor C07: collapse RegionService catch ladder via RegionFailureClassifier
ci/woodpecker/push/01-test Pipeline was successful
ci/woodpecker/push/02-build-push Pipeline was successful

Replace 9 nearly-identical catch blocks in
RegionService.ProcessRegionAsync with a single catch (Exception ex)
that delegates to RegionFailureClassifier.Classify, returning a
typed (category, errorMessage) pair. Preserves all original error
messages stored in region summary files; failure-path call into
HandleProcessingFailureAsync is unchanged.

Net source delta: -38 lines in RegionService, +71 lines in new
RegionFailureClassifier (pure static), +10 unit tests covering
each category, precedence, status-code propagation, null guard.

Tests: 68 unit (was 58) + 5 smoke + 3 stub-contract integration
tests pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 00:07:31 +03:00
parent 1d89cd9997
commit 5a28f67d33
8 changed files with 312 additions and 48 deletions
@@ -0,0 +1,70 @@
using SatelliteProvider.Common.Exceptions;
namespace SatelliteProvider.Services.RegionProcessing;
internal enum RegionFailureCategory
{
Timeout,
ExternalCancellation,
TaskCanceledOther,
OperationCanceledOther,
RateLimit,
Network,
Unexpected,
}
internal sealed record RegionFailureClassification(RegionFailureCategory Category, string ErrorMessage);
internal static class RegionFailureClassifier
{
public const string TimeoutMessage =
"Processing timed out after 5 minutes. Unable to download tiles within the time limit.";
public const string ExternalCancellationMessage =
"Processing was cancelled externally (likely application shutdown).";
public static RegionFailureClassification Classify(
Exception ex,
CancellationTokenSource timeoutCts,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(ex);
ArgumentNullException.ThrowIfNull(timeoutCts);
return ex switch
{
TaskCanceledException when timeoutCts.IsCancellationRequested
=> new RegionFailureClassification(RegionFailureCategory.Timeout, TimeoutMessage),
TaskCanceledException when cancellationToken.IsCancellationRequested
=> new RegionFailureClassification(RegionFailureCategory.ExternalCancellation, ExternalCancellationMessage),
TaskCanceledException
=> new RegionFailureClassification(
RegionFailureCategory.TaskCanceledOther,
$"Request cancelled or timed out: {ex.Message}. This may indicate HttpClient timeout or network issues."),
OperationCanceledException when timeoutCts.IsCancellationRequested
=> new RegionFailureClassification(RegionFailureCategory.Timeout, TimeoutMessage),
OperationCanceledException
=> new RegionFailureClassification(
RegionFailureCategory.OperationCanceledOther,
$"Operation cancelled: {ex.Message}"),
RateLimitException
=> new RegionFailureClassification(
RegionFailureCategory.RateLimit,
$"Rate limit exceeded: {ex.Message}. Google Maps API rate limit was reached and retries were exhausted."),
HttpRequestException httpEx
=> new RegionFailureClassification(
RegionFailureCategory.Network,
$"Network error (HTTP {httpEx.StatusCode}): {httpEx.Message}. Failed to download tiles from Google Maps."),
_ => new RegionFailureClassification(
RegionFailureCategory.Unexpected,
$"Unexpected error ({ex.GetType().Name}): {ex.Message}"),
};
}
}
@@ -145,54 +145,16 @@ public class RegionService : IRegionService
region.UpdatedAt = DateTime.UtcNow;
await _regionRepository.UpdateAsync(region);
}
catch (TaskCanceledException ex) when (timeoutCts.IsCancellationRequested)
{
errorMessage = "Processing timed out after 5 minutes. Unable to download tiles within the time limit.";
_logger.LogError(ex, "Region {RegionId} processing timed out after 5 minutes", id);
await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage);
}
catch (TaskCanceledException ex) when (cancellationToken.IsCancellationRequested)
{
errorMessage = "Processing was cancelled externally (likely application shutdown).";
_logger.LogError(ex, "Region {RegionId} processing was cancelled externally", id);
await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage);
}
catch (TaskCanceledException ex)
{
errorMessage = $"Request cancelled or timed out: {ex.Message}. This may indicate HttpClient timeout or network issues.";
_logger.LogError(ex, "Region {RegionId} processing was cancelled (TaskCanceledException)", id);
await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage);
}
catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested)
{
errorMessage = "Processing timed out after 5 minutes. Unable to download tiles within the time limit.";
_logger.LogError(ex, "Region {RegionId} processing timed out after 5 minutes", id);
await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage);
}
catch (OperationCanceledException ex)
{
errorMessage = $"Operation cancelled: {ex.Message}";
_logger.LogError(ex, "Region {RegionId} processing was cancelled", id);
await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage);
}
catch (RateLimitException ex)
{
errorMessage = $"Rate limit exceeded: {ex.Message}. Google Maps API rate limit was reached and retries were exhausted.";
_logger.LogError(ex, "Rate limit exceeded for region {RegionId}. Google is throttling requests. Consider reducing request rate.", id);
await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage);
}
catch (HttpRequestException ex)
{
errorMessage = $"Network error (HTTP {ex.StatusCode}): {ex.Message}. Failed to download tiles from Google Maps.";
_logger.LogError(ex, "Network error processing region {RegionId}. StatusCode: {StatusCode}, Message: {Message}",
id, ex.StatusCode, ex.Message);
await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage);
}
catch (Exception ex)
{
errorMessage = $"Unexpected error ({ex.GetType().Name}): {ex.Message}";
_logger.LogError(ex, "Failed to process region {RegionId}. Type: {ExceptionType}, Message: {Message}",
id, ex.GetType().Name, ex.Message);
var classification = RegionFailureClassifier.Classify(ex, timeoutCts, cancellationToken);
errorMessage = classification.ErrorMessage;
_logger.LogError(
ex,
"Region {RegionId} processing failed (category={Category}): {ErrorMessage}",
id,
classification.Category,
classification.ErrorMessage);
await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage);
}
}
@@ -19,4 +19,8 @@
<ProjectReference Include="..\SatelliteProvider.DataAccess\SatelliteProvider.DataAccess.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="SatelliteProvider.Tests" />
</ItemGroup>
</Project>