Error Handling

Learn how to handle exceptions and errors when using the .NET Custody SDK with comprehensive exception types and best practices.

Exception Hierarchy

All Custody SDK exceptions inherit from CustodyException, which provides structured error information:

CustodyException (base)
├── AuthException (401)
├── TokenExpiredException (401)
├── ForbiddenException (403)
│   └── RouteNotAvailableException (403, ErrorCode=ROUTE_NOT_AVAILABLE)
├── AuthorizationNotFoundException (404)
├── ValidationException (400, 422)
│   └── InvalidOriginAddressException (400, ErrorCode=INVALID_ORIGIN_ADDRESS)
├── ConflictException (409)
│   ├── DuplicateAuthorizationException (409)
│   ├── InvalidStateException (409)
│   ├── PoolConflictException (409, ErrorCode=POOL_CONFLICT)
│   └── VehicleNotReleasableException (409)
├── AuthorizationExpiredException (410)
├── RateLimitException (429)
│   └── PoolCapacityExceededException (429, ErrorCode=POOL_CAPACITY_EXCEEDED)
└── NetworkException (5xx, timeout)
    └── GeocodingServiceUnavailableException (503, ErrorCode=GEOCODING_SERVICE_UNAVAILABLE)

Base Exception Properties

All exceptions include these properties from CustodyException:

Messagestring

Human-readable error message describing what went wrong

ErrorCodestring

Programmatic error code for error identification (e.g., "AUTH_FAILED", "VALIDATION_FAILED")

StatusCodeHttpStatusCode

HTTP status code associated with the error (e.g., 400, 404, 500)

CorrelationIdstring

Correlation ID for request tracing across distributed systems. Use this for support tickets.

Exception Types

AuthException

Thrown when authentication fails (OAuth token acquisition or API key validation).

using CoxAuto.Vecu.CustodySdk.Exceptions;

try
{
    var auth = await custodyClient.GetAuthorizationAsync("AUTH-123");
}
catch (AuthException ex)
{
    // Handle authentication failure
    Console.WriteLine($"Authentication failed: {ex.Message}");
    Console.WriteLine($"Request ID: {ex.CorrelationId}");

    // Common causes:
    // - Invalid API key
    // - OAuth client credentials rejected
    // - Token endpoint unreachable
    // - Expired or revoked credentials
}

HTTP Status: 401 Unauthorized Error Code: AUTH_FAILED

TokenExpiredException

Thrown when an OAuth access token has expired and automatic refresh fails.

try
{
    var auth = await custodyClient.GetAuthorizationAsync("AUTH-123");
}
catch (TokenExpiredException ex)
{
    // Token expired and couldn't be refreshed
    // This is rare as the SDK auto-refreshes tokens

    Console.WriteLine($"Token expired: {ex.Message}");

    // Solution: Check OAuth credentials and token endpoint connectivity
}

HTTP Status: 401 Unauthorized Error Code: TOKEN_EXPIRED

ForbiddenException

Thrown when the Custody Service returns an HTTP 403 response with a structured application-layer error body (for example, an insufficient-scope denial). Serves as the shared base class for all typed 403 exceptions, so consumers can catch any forbidden response with a single handler while still being able to branch on specific error codes.

using CoxAuto.Vecu.CustodySdk.Exceptions;

try
{
    var auth = await custodyClient.GetAuthorizationAsync("AUTH-123");
}
catch (ForbiddenException ex)
{
    Console.WriteLine($"Forbidden: {ex.Message}");
    Console.WriteLine($"Error code: {ex.ErrorCode}");
    Console.WriteLine($"CorrelationId: {ex.CorrelationId}");

    // Common causes:
    // - Bearer token has insufficient scope for the requested resource
    // - Per-object authorization denied (future BOLA enforcement)
    // - Service disabled for the caller's tenant
}

HTTP Status: 403 Forbidden Error Code: varies (propagated from the server's structured error field; defaults to FORBIDDEN when the server does not supply one) Inherits From: CustodyException

Introduced in v3.0.0

Prior to v3.0.0, HTTP 403 responses were thrown as NetworkException with StatusCode=ServiceUnavailable. Retry-logic callers would spin-retry a non-transient permission failure. In v3.0.0, 403 responses now throw ForbiddenException (or RouteNotAvailableException) with StatusCode=Forbidden so that consumers can classify and handle them correctly. See the Changelog for migration guidance.

RouteNotAvailableException

Thrown when the Custody Service returns an HTTP 403 response indicating that the route exists at the edge but is not wired to a back-end method (removed, not yet deployed, or not available in this environment).

using CoxAuto.Vecu.CustodySdk.Exceptions;

try
{
    var auth = await custodyClient.GetAuthorizationAsync("AUTH-123");
}
catch (RouteNotAvailableException ex)
{
    Console.WriteLine($"Route not available: {ex.Message}");
    Console.WriteLine($"CorrelationId: {ex.CorrelationId}");

    // This indicates the SDK is attempting to hit a route that the server has
    // removed or not yet restored. Do not retry — upgrade the SDK, or wait
    // for the server-side BOLA re-enablement tracker to be resolved.
}

HTTP Status: 403 Forbidden Error Code: ROUTE_NOT_AVAILABLE Inherits From: ForbiddenExceptionCustodyException

How the 403 classifier decides

RouteNotAvailableException is raised only when the 403 response body is an exact match for the API Gateway default — a JSON object whose lowercase message property is exactly the string "Forbidden". Every other 403 body — WAF ({"message":"Request blocked"}), Cognito/IAM ({"Message":"User is not authorized..."} with a capital M), arbitrary gateway messages, malformed JSON, and empty bodies — surfaces as a plain ForbiddenException. This keeps operators from triaging a WAF or authorizer incident as a "route-removed" problem.

AuthorizationNotFoundException

Thrown when an authorization ID doesn't exist or has been deleted.

try
{
    var auth = await custodyClient.GetAuthorizationAsync("AUTH-NONEXISTENT");
}
catch (AuthorizationNotFoundException ex)
{
    Console.WriteLine($"Authorization not found: {ex.AuthorizationId}");
    Console.WriteLine($"Request ID: {ex.CorrelationId}");

    // Common causes:
    // - Wrong authorization ID
    // - Authorization deleted
    // - Authorization from different environment
}

HTTP Status: 404 Not Found Error Code: AUTHORIZATION_NOT_FOUND Additional Properties:

  • AuthorizationId (string) - The ID that wasn't found

ValidationException

Thrown when request validation fails. Includes detailed field-level errors.

try
{
    var request = new CreateAuthorizationRequest
    {
        Vin = "INVALID", // Too short
        Origin = "", // Empty
        Destination = "320 Harbor Way, Long Beach, CA 90802",
        PersonIdentityKey = "DL-109-283-745",
        MakeModel = "Ford F-150",
        AuthorizedBy = "SYSTEM"
    };

    var response = await custodyClient.CreateAuthorizationAsync(request);
}
catch (ValidationException ex)
{
    Console.WriteLine($"Validation failed: {ex.Message}");
    Console.WriteLine($"Request ID: {ex.CorrelationId}");

    // Access field-level errors
    foreach (var (field, errors) in ex.ValidationErrors)
    {
        Console.WriteLine($"  {field}:");
        foreach (var error in errors)
        {
            Console.WriteLine($"    - {error}");
        }
    }

    // Example output:
    // vin:
    //   - VIN must be exactly 17 characters
    // origin:
    //   - Origin facility ID is required
}

HTTP Status: 400 Bad Request or 422 Unprocessable Entity Error Code: VALIDATION_FAILED Additional Properties:

  • ValidationErrors (IReadOnlyDictionary<string, string[]>) - Field-level validation errors

InvalidOriginAddressException

Thrown when the upstream geocoder (Amazon Location Service) declines the submitted Origin address during canonicalization (ADR-041). The address itself is the problem — retrying with the same string will keep failing — so this is not a transient error.

using CoxAuto.Vecu.CustodySdk.Exceptions;

try
{
    var request = new CreateAuthorizationRequest
    {
        Vin = "1HGBH41JXMN109186",
        Origin = "asdfghjkl",  // not a real address
        Destination = "200 Motor Ave, Columbus, OH 43230",
        PersonIdentityKey = "DL-293-847-561",
        MakeModel = "Honda Accord",
        AuthorizedBy = "SYSTEM"
    };

    var response = await custodyClient.CreateAuthorizationAsync(request);
}
catch (InvalidOriginAddressException ex)
{
    var operatorMessage = ex.Reason switch
    {
        OriginRejectionReason.LowRelevance =>
            "We couldn't confidently locate that address. Please add a city, " +
            "state, or ZIP and try again.",
        OriginRejectionReason.NoMatch =>
            "We couldn't find that address. Double-check the street and " +
            "number, or pick a nearby place.",
        OriginRejectionReason.Interpolated =>
            "That looks like a point on a road rather than a real building. " +
            "Use a building address or a nearby place name.",
        OriginRejectionReason.InvalidCategory =>
            "That address is recognized but not allowed for custody origins " +
            "(e.g., it's residential where commercial is required).",
        _ => "The origin address could not be canonicalized."
    };

    Console.WriteLine(operatorMessage);
    Console.WriteLine($"CorrelationId: {ex.CorrelationId}");
}

HTTP Status: 400 Bad Request Error Code: INVALID_ORIGIN_ADDRESS Inherits From: ValidationExceptionCustodyException Retryable: No — the address itself is rejected. Additional Properties:

  • Reason (OriginRejectionReason enum) - Why the geocoder declined the address. One of LowRelevance, NoMatch, Interpolated, InvalidCategory.

The Reason enum maps the wire-format LOW_RELEVANCE / NO_MATCH / INTERPOLATED / INVALID_CATEGORY (SCREAMING_SNAKE) to PascalCase per C# enum conventions. Useful when correlating SDK runtime errors against raw response payloads in logs.

Introduced in v3.2.0

This exception surfaces ADR-041 origin canonicalization and requires a custody service that has shipped ADR-041 (non-prod and beyond). Earlier service versions raise a generic ValidationException for the same geocoder-rejection conditions. Catch InvalidOriginAddressException before the more general ValidationException to give callers address-specific UX without losing the field-level errors handled by the base type.

DuplicateAuthorizationException

Thrown when attempting to create an authorization that already exists for the same VIN and canonicalized origin under the request's effective uniqueness scope. The shape of this exception was extended in v3.2.0 to surface the ADR-040 same-company scope, the disclosure-guard contract on the 409 envelope, and the fail-closed enrichment behavior — all of which can leave the ExistingAuthorization subobject null on a v3.2.0+ service.

try
{
    var request = new CreateAuthorizationRequest
    {
        Vin = "1TEST5678MOCK1234",
        Origin = "500 Commerce Rd, Richmond, VA 23234",
        Destination = "750 Industrial Pkwy, Dallas, TX 75247",
        PersonIdentityKey = "DL-109-283-745",
        MakeModel = "Toyota Camry",
        AuthorizedBy = "SYSTEM",
        OrganizationId = "acme-fleet" // optional — opt in to same-company scope
    };

    var response = await custodyClient.CreateAuthorizationAsync(request);
}
catch (DuplicateAuthorizationException ex)
{
    Console.WriteLine($"Duplicate authorization: {ex.Message}");
    Console.WriteLine($"EffectiveScope: {ex.EffectiveScope}");
    Console.WriteLine($"OrganizationIdPresent: {ex.OrganizationIdPresent}");
    Console.WriteLine($"CorrelationId: {ex.CorrelationId}");

    // Safe-access pattern (REQUIRED in v3.2.0+).
    // ExistingAuthorization / ExistingAuthorizationId are NULLABLE.
    // Gate on the null check ALONE — do not gate on OrganizationIdPresent,
    // because strict-scope same-client conflicts pass the disclosure guard
    // and DO populate the existing record with OrganizationIdPresent=false.
    if (ex.ExistingAuthorization is not null)
    {
        // Disclosure guard passed AND enrichment succeeded — reuse or
        // supersede the existing record.
        var existingAuth = await custodyClient.GetAuthorizationAsync(
            ex.ExistingAuthorization.AuthorizationId);
    }
    else
    {
        // Existing record not surfaced. Could be the disclosure guard
        // (cross-company conflict where the request and existing record
        // disagree on OrganizationId), or a transient enrichment failure
        // — the 409 side-channel does NOT distinguish, and per ADR-040
        // §7.1 you MUST NOT infer disclosure-guard denial from null.
        // If you need a deterministic check, use a fresh
        // GET /authorizations/{id} with a known ID instead.
    }
}

HTTP Status: 409 Conflict Error Code: DUPLICATE_AUTHORIZATION Additional Properties:

  • EffectiveScope (UniquenessScope) - Which uniqueness scope was applied: VinOriginStrict (no OrganizationId supplied) or VinOriginSameCompanyAllowed (request was scoped to an organization).
  • OrganizationIdPresent (bool) - true if the original request supplied an OrganizationId; false otherwise. Informational only — useful for logs and metrics, but NOT the discriminator for whether the existing record is surfaced (strict-scope same-client conflicts pass the disclosure guard with OrganizationIdPresent=false).
  • ExistingAuthorization (AuthorizationResponse?) - Nullable as of v3.2.0. Populated when the ADR-040 §7.1 disclosure guard passes (same clientId OR matching non-null OrganizationId on both records) AND the service can enrich the response. Null otherwise — including when the service hits a transient enrichment failure on a guard-eligible conflict and falls back to the minimal envelope (fail-closed).
  • ExistingAuthorizationId (string?) - Nullable as of v3.2.0. Convenience accessor for ExistingAuthorization?.AuthorizationId; same null semantics.
  • LocationUri (Uri?) - New in v3.2.0. Parsed value of the response Location header when the service emits one on the 409 envelope. Tracks the same nullability semantics as ExistingAuthorization: populated when the disclosure guard passes (same clientId OR matching non-null OrganizationId on both records) AND service-side enrichment succeeded; null on cross-company conflicts and on enrichment fail-closed cases. Points at GET /v1/authorizations/{id} for the existing authorization. Equivalent to navigating ex.ExistingAuthorization?.AuthorizationId and constructing the URL yourself, but provided pre-parsed for convenience.

Migration Notes — Nullable Existing* Fields (v3.2.0)

Code that unconditionally dereferences ex.ExistingAuthorizationId (or ex.ExistingAuthorization) after catching DuplicateAuthorizationException will throw NullReferenceException against a v3.2.0+ service. Gate on ex.ExistingAuthorization is not null (or use the ?. null-conditional access) before calling GetAuthorizationAsync with the existing ID.

Do NOT gate on ex.OrganizationIdPresent — strict-scope same-client conflicts pass the disclosure guard with OrganizationIdPresent=false and DO populate the existing record. Do NOT infer disclosure-guard denial from a null ExistingAuthorization — per ADR-040 §7.1, the service also returns the minimal envelope on transient enrichment failures, and the 409 side-channel does not distinguish. If you need a deterministic disclosure signal, use a fresh GET /authorizations/{id} with a known ID rather than the 409.

VehicleNotReleasableException

Thrown when a vehicle cannot be released due to blockers (liens, holds, etc.).

try
{
    var result = await custodyClient.GetReleasabilityAsync(
        "1TEST5678MOCK1234",
        "test-origin");
}
catch (VehicleNotReleasableException ex)
{
    Console.WriteLine($"Vehicle not releasable: {ex.Vin}");
    Console.WriteLine($"Blockers: {string.Join(", ", ex.Blockers)}");
    Console.WriteLine($"Request ID: {ex.CorrelationId}");

    // Example blockers:
    // - "Active lien from BANK_OF_AMERICA"
    // - "Pending title transfer"
    // - "Court-ordered hold"
}

HTTP Status: 409 Conflict Error Code: VEHICLE_NOT_RELEASABLE Additional Properties:

  • Vin (string) - The VIN that's not releasable
  • Blockers (IReadOnlyList<string>) - List of reasons why the vehicle can't be released

AuthorizationExpiredException

Thrown when attempting to use an expired authorization.

try
{
    // Attempting to use an authorization that exceeded its validity period
    var auth = await custodyClient.GetAuthorizationAsync("AUTH-EXPIRED-123");

    if (auth.Status == AuthorizationStatus.EXPIRED)
    {
        Console.WriteLine("Authorization has expired");
    }
}
catch (AuthorizationExpiredException ex)
{
    Console.WriteLine($"Authorization expired: {ex.AuthorizationId}");
    Console.WriteLine($"Expired at: {ex.ExpiredAt}");
    Console.WriteLine($"Request ID: {ex.CorrelationId}");

    // Solution: Create a new authorization
}

HTTP Status: 410 Gone Error Code: AUTHORIZATION_EXPIRED Additional Properties:

  • AuthorizationId (string) - The expired authorization ID
  • ExpiredAt (DateTimeOffset) - When the authorization expired

RateLimitException

Thrown when API rate limits are exceeded.

try
{
    // Making too many requests in a short period
    for (int i = 0; i < 1000; i++)
    {
        await custodyClient.GetAuthorizationAsync($"AUTH-{i}");
    }
}
catch (RateLimitException ex)
{
    Console.WriteLine($"Rate limit exceeded: {ex.Message}");
    Console.WriteLine($"Retry after: {ex.RetryAfter?.TotalSeconds} seconds");
    Console.WriteLine($"Request ID: {ex.CorrelationId}");

    // Wait before retrying
    if (ex.RetryAfter.HasValue)
    {
        await Task.Delay(ex.RetryAfter.Value);
    }
}

HTTP Status: 429 Too Many Requests Error Code: RATE_LIMIT_EXCEEDED Additional Properties:

  • RetryAfter (TimeSpan?) - How long to wait before retrying

PoolCapacityExceededException

Thrown when the authorization pool has hit the per-VIN concurrent-authorization cap under the effective ADR-040 uniqueness scope. The server returns HTTP 429 with ErrorCode=POOL_CAPACITY_EXCEEDED.

using CoxAuto.Vecu.CustodySdk.Exceptions;

try
{
    var response = await custodyClient.CreateAuthorizationAsync(request);
}
catch (PoolCapacityExceededException ex)
{
    Console.WriteLine($"Pool at capacity: {ex.CurrentCount}/{ex.MaxAllowed}");
    Console.WriteLine($"EffectiveScope: {ex.EffectiveScope}");
    Console.WriteLine($"OrganizationIdPresent: {ex.OrganizationIdPresent}");
    Console.WriteLine($"CorrelationId: {ex.CorrelationId}");

    // Pool-cap 429 carries NO Retry-After header — the cap clears on an
    // event, not a time window. Drive retry off EventBridge events for
    // the same (VIN, origin):
    //   - custody.authorization.cancelled
    //   - custody.vehicle.released
    // ...or fall back to a bounded exponential backoff if you don't
    // consume those events. Two structural fixes are usually better
    // than retrying:
    //   1. Cancel a stale authorization on this VIN, then retry.
    //   2. If you're hitting the strict-scope cap and you have a
    //      legitimate same-company use case, supply OrganizationId on
    //      the request to apply the VinOriginSameCompanyAllowed scope.
}

HTTP Status: 429 Too Many Requests Error Code: POOL_CAPACITY_EXCEEDED Inherits From: RateLimitExceptionCustodyException Additional Properties:

  • EffectiveScope (UniquenessScope) - Scope under which the cap was hit.
  • OrganizationIdPresent (bool) - Whether the request was scoped to an organization.
  • CurrentCount (int) - Active concurrent authorizations on this VIN under the effective scope at the time of the request.
  • MaxAllowed (int) - The cap configured for the effective scope.

Introduced in v3.2.0

Existing catch (RateLimitException) handlers continue to work unchanged. Catch PoolCapacityExceededException first when you need to branch on EffectiveScope / CurrentCount / MaxAllowed.

NetworkException

Thrown when network errors occur (timeouts, connection failures, 5xx server errors).

try
{
    var auth = await custodyClient.GetAuthorizationAsync("AUTH-123");
}
catch (NetworkException ex)
{
    Console.WriteLine($"Network error: {ex.Message}");
    Console.WriteLine($"Request ID: {ex.CorrelationId}");

    // Common causes:
    // - Request timeout
    // - Connection refused
    // - DNS resolution failure
    // - Service temporarily unavailable (503)
    // - Internal server error (500)

    // The SDK automatically retries transient failures
}

HTTP Status: 5xx Server Error or connection failure Error Code: NETWORK_ERROR

GeocodingServiceUnavailableException

Thrown when the upstream geocoder (Amazon Location Service) is unreachable or returning errors and the custody service cannot canonicalize the origin (ADR-041). The server returns HTTP 503 with ErrorCode=GEOCODING_SERVICE_UNAVAILABLE. This is transient — the SDK's resilience pipeline will retry automatically, honoring the Retry-After header on the response (typical range 30–300 s).

using CoxAuto.Vecu.CustodySdk.Exceptions;

try
{
    var response = await custodyClient.CreateAuthorizationAsync(request);
}
catch (GeocodingServiceUnavailableException ex)
{
    // The SDK has already exhausted its built-in retry budget by the time
    // this exception surfaces. Higher-level options:
    //   - Defer the request to a background job and retry later.
    //   - Surface a "geocoding temporarily unavailable, please retry" UX.

    Console.WriteLine($"Geocoder unavailable: {ex.Message}");
    Console.WriteLine($"Server-suggested retry: {ex.RetryAfterSeconds}s");
    Console.WriteLine($"CorrelationId: {ex.CorrelationId}");
}

HTTP Status: 503 Service Unavailable Error Code: GEOCODING_SERVICE_UNAVAILABLE Inherits From: NetworkExceptionCustodyException Retryable: Yes (Retryable is always true on this type). Additional Properties:

  • RetryAfterSeconds (int?) - Server-suggested delay parsed from the Retry-After response header (delta-seconds or HTTP-date). null when the header is absent or unparseable.
  • Retryable (bool) - Always true. Provided so generic catch (CustodyException) handlers can branch on the property without type-checking.

Introduced in v3.2.0

Existing catch (NetworkException) handlers continue to work unchanged. The SDK's Polly resilience pipeline now honors the Retry-After header on 503 responses automatically; this exception surfaces only after the built-in retry budget is exhausted.

Retry Strategy

The SDK's built-in resilience pipeline (Polly) handles transient failures automatically. The behaviors worth calling out:

  • Retry-After is honored on 503 responses, including GEOCODING_SERVICE_UNAVAILABLE (v3.2.0+). When the server emits a Retry-After header (delta-seconds integer or HTTP-date — typical range 30–300 s for the geocoder envelope), the SDK uses it as the delay before the next attempt. When the header is missing or malformed, the SDK falls back to its existing exponential backoff schedule. GeocodingServiceUnavailableException.RetryAfterSeconds exposes the same value to consumers writing custom retry logic on top.
  • Defensive 5-minute upper cap on retry delays (v3.2.0+). Every Retry-After-derived delay AND every exponential-backoff fallback is capped at 5 minutes. A buggy or malicious upstream emitting Retry-After: 9999999 (or a far-future HTTP-date) would otherwise cause Polly to schedule a multi-day wait, hanging callers that don't pass a CancellationToken. The cap clamps such values down to 5 min. Consumers driving their own retry logic off RetryAfterSeconds should apply their own bounding if they want different limits. The total retry budget (number of attempts) is unchanged.
  • Pool-cap 429 (PoolCapacityExceededException) carries NO Retry-After header. The cap clears on an event, not a time window — specifically, when an existing authorization for the same (VIN, origin) is cancelled or its vehicle released. The general RateLimitException parent class still surfaces Retry-After when the server provides one (e.g., on edge-throttling 429s), but for the pool-cap subclass that property will be null. Recommended retry signal:
    • Subscribe to the custody.authorization.cancelled and custody.vehicle.released EventBridge events for the affected (VIN, origin) pair, and retry the create when one fires.
    • Or fall back to a bounded exponential backoff if you don't consume those events.
  • No retries on 4xx (other than 429). InvalidOriginAddressException (400) and DuplicateAuthorizationException (409) are terminal — the SDK will not retry them.

Error Handling Patterns

Basic Try-Catch

Handle specific exceptions first, then fall back to base exception:

using CoxAuto.Vecu.CustodySdk.Client;
using CoxAuto.Vecu.CustodySdk.Exceptions;

public class AuthorizationService
{
    private readonly ICustodyServiceClient _custodyClient;
    private readonly ILogger<AuthorizationService> _logger;

    public async Task<AuthorizationDetails> GetAuthorizationSafelyAsync(string authorizationId)
    {
        try
        {
            return await _custodyClient.GetAuthorizationAsync(authorizationId);
        }
        catch (AuthorizationNotFoundException ex)
        {
            _logger.LogWarning(
                "Authorization {AuthId} not found (CorrelationId: {CorrelationId})",
                ex.AuthorizationId,
                ex.CorrelationId
            );
            throw; // Re-throw or return default
        }
        catch (AuthException ex)
        {
            _logger.LogError(
                ex,
                "Authentication failed (CorrelationId: {CorrelationId})",
                ex.CorrelationId
            );
            throw;
        }
        catch (NetworkException ex)
        {
            _logger.LogError(
                ex,
                "Network error (CorrelationId: {CorrelationId})",
                ex.CorrelationId
            );
            throw;
        }
        catch (CustodyException ex)
        {
            _logger.LogError(
                ex,
                "Custody API error: {ErrorCode} (CorrelationId: {CorrelationId})",
                ex.ErrorCode,
                ex.CorrelationId
            );
            throw;
        }
    }
}

Pattern Matching with Switch Expression

Use C# pattern matching for cleaner error handling:

public async Task<IActionResult> CreateAuthorizationAsync(
    CreateAuthorizationRequest request)
{
    try
    {
        var response = await _custodyClient.CreateAuthorizationAsync(request);
        return Ok(response);
    }
    catch (CustodyException ex)
    {
        return ex switch
        {
            ValidationException validation => BadRequest(new
            {
                error = "Validation failed",
                errors = validation.ValidationErrors,
                correlationId = validation.CorrelationId
            }),

            DuplicateAuthorizationException duplicate => Conflict(new
            {
                error = "Duplicate authorization",
                existingId = duplicate.ExistingAuthorizationId,
                correlationId = duplicate.CorrelationId
            }),

            VehicleNotReleasableException notReleasable => Conflict(new
            {
                error = "Vehicle not releasable",
                vin = notReleasable.Vin,
                blockers = notReleasable.Blockers,
                correlationId = notReleasable.CorrelationId
            }),

            AuthException => Unauthorized(new
            {
                error = "Authentication failed",
                correlationId = ex.CorrelationId
            }),

            RateLimitException rateLimit => StatusCode(429, new
            {
                error = "Rate limit exceeded",
                retryAfter = rateLimit.RetryAfter?.TotalSeconds,
                correlationId = rateLimit.CorrelationId
            }),

            _ => StatusCode(500, new
            {
                error = "Internal error",
                message = ex.Message,
                errorCode = ex.ErrorCode,
                correlationId = ex.CorrelationId
            })
        };
    }
}

Validation Error Handling

Process field-level validation errors for user-friendly responses:

public async Task<Result<AuthorizationResponse>> CreateAuthorizationWithValidationAsync(
    CreateAuthorizationRequest request)
{
    try
    {
        var response = await _custodyClient.CreateAuthorizationAsync(request);
        return Result<AuthorizationResponse>.Success(response);
    }
    catch (ValidationException ex)
    {
        // Convert to application-specific error model
        var errors = ex.ValidationErrors
            .SelectMany(kvp => kvp.Value.Select(msg => new ValidationError
            {
                Field = kvp.Key,
                Message = msg
            }))
            .ToList();

        return Result<AuthorizationResponse>.Failure(errors);
    }
}

public class Result<T>
{
    public bool IsSuccess { get; init; }
    public T? Value { get; init; }
    public List<ValidationError> Errors { get; init; } = new();

    public static Result<T> Success(T value) => new()
    {
        IsSuccess = true,
        Value = value
    };

    public static Result<T> Failure(List<ValidationError> errors) => new()
    {
        IsSuccess = false,
        Errors = errors
    };
}

public record ValidationError
{
    public string Field { get; init; } = string.Empty;
    public string Message { get; init; } = string.Empty;
}

Retry Handling with Polly

The SDK has built-in retry logic, but you can add custom retry policies:

using Polly;
using Polly.Retry;

public class ResilientAuthorizationService
{
    private readonly ICustodyServiceClient _custodyClient;
    private readonly AsyncRetryPolicy _retryPolicy;

    public ResilientAuthorizationService(ICustodyServiceClient custodyClient)
    {
        _custodyClient = custodyClient;

        // Custom retry policy for specific scenarios
        _retryPolicy = Policy
            .Handle<RateLimitException>()
            .Or<NetworkException>()
            .WaitAndRetryAsync(
                retryCount: 3,
                sleepDurationProvider: (retryAttempt, exception, context) =>
                {
                    // Use RetryAfter from RateLimitException if available
                    if (exception is RateLimitException rateLimitEx &&
                        rateLimitEx.RetryAfter.HasValue)
                    {
                        return rateLimitEx.RetryAfter.Value;
                    }

                    // Exponential backoff for network errors
                    return TimeSpan.FromSeconds(Math.Pow(2, retryAttempt));
                },
                onRetryAsync: async (exception, timespan, retryCount, context) =>
                {
                    Console.WriteLine(
                        $"Retry {retryCount} after {timespan.TotalSeconds}s due to {exception.GetType().Name}"
                    );
                    await Task.CompletedTask;
                }
            );
    }

    public async Task<AuthorizationDetails> GetAuthorizationWithRetryAsync(
        string authorizationId)
    {
        return await _retryPolicy.ExecuteAsync(async () =>
            await _custodyClient.GetAuthorizationAsync(authorizationId)
        );
    }
}

The SDK already implements exponential backoff retry for transient failures. Additional Polly policies are only needed for custom retry scenarios.

Correlation IDs for Support

Always log CorrelationId for troubleshooting and support tickets:

try
{
    var auth = await custodyClient.GetAuthorizationAsync(authorizationId);
}
catch (CustodyException ex)
{
    _logger.LogError(
        ex,
        "Custody API error: {ErrorCode}, StatusCode: {StatusCode}, CorrelationId: {CorrelationId}",
        ex.ErrorCode,
        ex.StatusCode,
        ex.CorrelationId // CRITICAL: Include in logs and error responses
    );

    // Return CorrelationId to client for support tickets
    return new ErrorResponse
    {
        Message = "An error occurred processing your request",
        ErrorCode = ex.ErrorCode,
        CorrelationId = ex.CorrelationId, // Client can provide this to support
        Timestamp = DateTimeOffset.UtcNow
    };
}

Best Practices

1. Handle Specific Exceptions First

Always catch specific exceptions before the base CustodyException:

try
{
    await custodyClient.CreateAuthorizationAsync(request);
}
catch (ValidationException ex) { /* Handle validation */ }
catch (DuplicateAuthorizationException ex) { /* Handle duplicate */ }
catch (CustodyException ex) { /* Handle all others */ }

2. Log CorrelationId for Every Error

The CorrelationId is essential for distributed tracing and support:

catch (CustodyException ex)
{
    _logger.LogError(
        ex,
        "Error occurred. CorrelationId: {CorrelationId}, ErrorCode: {ErrorCode}",
        ex.CorrelationId,
        ex.ErrorCode
    );
}

3. Don't Swallow Exceptions Silently

Always log or re-throw exceptions:

// BAD: Silent failure
try
{
    await custodyClient.GetAuthorizationAsync(authId);
}
catch { }

// GOOD: Log and re-throw or handle
try
{
    await custodyClient.GetAuthorizationAsync(authId);
}
catch (CustodyException ex)
{
    _logger.LogError(ex, "Failed to get authorization");
    throw; // Re-throw or handle appropriately
}

4. Return User-Friendly Error Messages

Translate technical exceptions into user-friendly responses:

catch (ValidationException ex)
{
    return BadRequest(new
    {
        message = "Please correct the following errors:",
        errors = ex.ValidationErrors.SelectMany(kvp =>
            kvp.Value.Select(msg => $"{kvp.Key}: {msg}")
        ).ToList()
    });
}

5. Use Structured Logging

Include contextual information in logs for better observability:

_logger.LogError(
    ex,
    "Failed to create authorization for VIN {Vin}, Origin {Origin}, Destination {Destination}. CorrelationId: {CorrelationId}",
    request.Vin,
    request.Origin,
    request.Destination,
    ex.CorrelationId
);

Next Steps