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:
MessagestringHuman-readable error message describing what went wrong
ErrorCodestringProgrammatic error code for error identification (e.g., "AUTH_FAILED", "VALIDATION_FAILED")
StatusCodeHttpStatusCodeHTTP status code associated with the error (e.g., 400, 404, 500)
CorrelationIdstringCorrelation 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: ForbiddenException → CustodyException
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: ValidationException → CustodyException
Retryable: No — the address itself is rejected.
Additional Properties:
Reason(OriginRejectionReasonenum) - Why the geocoder declined the address. One ofLowRelevance,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(noOrganizationIdsupplied) orVinOriginSameCompanyAllowed(request was scoped to an organization).OrganizationIdPresent(bool) -trueif the original request supplied anOrganizationId;falseotherwise. 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 withOrganizationIdPresent=false).ExistingAuthorization(AuthorizationResponse?) - Nullable as of v3.2.0. Populated when the ADR-040 §7.1 disclosure guard passes (sameclientIdOR matching non-nullOrganizationIdon 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 forExistingAuthorization?.AuthorizationId; same null semantics.LocationUri(Uri?) - New in v3.2.0. Parsed value of the responseLocationheader when the service emits one on the 409 envelope. Tracks the same nullability semantics asExistingAuthorization: populated when the disclosure guard passes (sameclientIdOR matching non-nullOrganizationIdon both records) AND service-side enrichment succeeded; null on cross-company conflicts and on enrichment fail-closed cases. Points atGET /v1/authorizations/{id}for the existing authorization. Equivalent to navigatingex.ExistingAuthorization?.AuthorizationIdand 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 releasableBlockers(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 IDExpiredAt(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: RateLimitException → CustodyException
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: NetworkException → CustodyException
Retryable: Yes (Retryable is always true on this type).
Additional Properties:
RetryAfterSeconds(int?) - Server-suggested delay parsed from theRetry-Afterresponse header (delta-seconds or HTTP-date).nullwhen the header is absent or unparseable.Retryable(bool) - Alwaystrue. Provided so genericcatch (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-Afteris honored on 503 responses, includingGEOCODING_SERVICE_UNAVAILABLE(v3.2.0+). When the server emits aRetry-Afterheader (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.RetryAfterSecondsexposes 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 emittingRetry-After: 9999999(or a far-future HTTP-date) would otherwise cause Polly to schedule a multi-day wait, hanging callers that don't pass aCancellationToken. The cap clamps such values down to 5 min. Consumers driving their own retry logic offRetryAfterSecondsshould apply their own bounding if they want different limits. The total retry budget (number of attempts) is unchanged. - Pool-cap 429 (
PoolCapacityExceededException) carries NORetry-Afterheader. 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 generalRateLimitExceptionparent class still surfacesRetry-Afterwhen the server provides one (e.g., on edge-throttling 429s), but for the pool-cap subclass that property will benull. Recommended retry signal:- Subscribe to the
custody.authorization.cancelledandcustody.vehicle.releasedEventBridge 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.
- Subscribe to the
- No retries on 4xx (other than 429).
InvalidOriginAddressException(400) andDuplicateAuthorizationException(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
- ASP.NET Core Integration - Exception middleware patterns
- Testing - Test error scenarios with mock exceptions
- API Reference - Complete exception documentation