Retry Logic & Exception Monitoring
After a delivery exception is reported, the operations team can schedule a redelivery attempt. This presentation covers building the retry endpoint with attempt limit enforcement, the automatic transition to Returned status, and a monitoring endpoint for parcels in exception status.
Retry Endpoint Design
The retry endpoint schedules a new delivery attempt for a parcel currently in Exception status:
POST /api/parcels/{id}/retry
The request body includes the new estimated delivery date:
json1{2 "newEstimatedDeliveryDate": "2025-02-18T00:00:00Z"3}
A successful response returns the updated parcel with 200 OK. If the parcel has reached the maximum number of delivery attempts, the retry is denied.
The Request DTO
csharp1public class RetryDeliveryRequest2{3 public DateTime NewEstimatedDeliveryDate { get; set; }4}
The new estimated delivery date tells the recipient when to expect the next attempt. This date must be in the future, which we validate in the controller action.
Maximum Attempt Limit
The system enforces a maximum of 3 delivery attempts. This constant is defined once and referenced by the retry logic:
csharp1private const int MaxDeliveryAttempts = 3;
When the attempt count reaches this limit, the parcel cannot be retried. Instead, it transitions to Returned status. Defining this as a named constant makes it easy to find and modify if the business rule changes.
The Retry Controller Action
Here is the complete implementation:
csharp1[HttpPost("{id}/retry")]2public async Task<ActionResult<ParcelResponse>> RetryDelivery(3 Guid id,4 RetryDeliveryRequest request)5{6 var parcel = await _context.Parcels7 .Include(p => p.RecipientAddress)8 .FirstOrDefaultAsync(p => p.Id == id);910 if (parcel == null)11 return NotFound(new { message = "Parcel not found" });1213 if (parcel.Status != ParcelStatus.Exception)14 {15 return BadRequest(new16 {17 message = $"Cannot retry delivery for parcel in {parcel.Status} status. " +18 "Only parcels in Exception status can be retried."19 });20 }2122 if (parcel.DeliveryAttempts >= MaxDeliveryAttempts)23 {24 parcel.Status = ParcelStatus.Returned;25 parcel.UpdatedAt = DateTime.UtcNow;2627 var returnEvent = new TrackingEvent28 {29 ParcelId = parcel.Id,30 EventType = EventType.Returned,31 Description = $"Parcel returned to sender after {MaxDeliveryAttempts} " +32 "failed delivery attempts",33 Timestamp = DateTime.UtcNow34 };3536 _context.TrackingEvents.Add(returnEvent);37 await _context.SaveChangesAsync();3839 return BadRequest(new40 {41 message = $"Maximum delivery attempts ({MaxDeliveryAttempts}) reached. " +42 "Parcel has been returned to sender.",43 parcel = MapToResponse(parcel)44 });45 }4647 parcel.Status = ParcelStatus.InTransit;48 parcel.EstimatedDeliveryDate = request.NewEstimatedDeliveryDate;49 parcel.UpdatedAt = DateTime.UtcNow;5051 var trackingEvent = new TrackingEvent52 {53 ParcelId = parcel.Id,54 EventType = EventType.InTransit,55 Description = $"Redelivery scheduled. New estimated delivery: " +56 $"{request.NewEstimatedDeliveryDate:yyyy-MM-dd}",57 Timestamp = DateTime.UtcNow,58 LocationCity = parcel.RecipientAddress?.City,59 LocationState = parcel.RecipientAddress?.State,60 LocationCountry = parcel.RecipientAddress?.CountryCode61 };6263 _context.TrackingEvents.Add(trackingEvent);64 await _context.SaveChangesAsync();6566 return Ok(MapToResponse(parcel));67}
Let us examine each section of this action.
Status Validation
csharp1if (parcel.Status != ParcelStatus.Exception)2{3 return BadRequest(new4 {5 message = $"Cannot retry delivery for parcel in {parcel.Status} status. " +6 "Only parcels in Exception status can be retried."7 });8}
Only parcels in Exception status can be retried. A parcel that is InTransit, Delivered, or Returned does not need a retry. The error message tells the client exactly what went wrong and what status is required.
Enforcing the Maximum Attempt Limit
csharp1if (parcel.DeliveryAttempts >= MaxDeliveryAttempts)2{3 parcel.Status = ParcelStatus.Returned;4 parcel.UpdatedAt = DateTime.UtcNow;56 var returnEvent = new TrackingEvent7 {8 ParcelId = parcel.Id,9 EventType = EventType.Returned,10 Description = $"Parcel returned to sender after {MaxDeliveryAttempts} " +11 "failed delivery attempts",12 Timestamp = DateTime.UtcNow13 };1415 _context.TrackingEvents.Add(returnEvent);16 await _context.SaveChangesAsync();1718 return BadRequest(new19 {20 message = $"Maximum delivery attempts ({MaxDeliveryAttempts}) reached. " +21 "Parcel has been returned to sender.",22 parcel = MapToResponse(parcel)23 });24}
When the attempt limit is reached, the system does not simply reject the request. It takes action:
- Transitions the parcel to
Returnedstatus - Creates a
Returnedtracking event explaining why - Saves both changes
- Returns a
400 Bad Requestwith the updated parcel data
This auto-transition is a business rule enforcement. The caller requested a retry, but the system determined the parcel should be returned instead. Including the updated parcel in the response lets the client see the new status immediately.
The Happy Path: Scheduling Redelivery
csharp1parcel.Status = ParcelStatus.InTransit;2parcel.EstimatedDeliveryDate = request.NewEstimatedDeliveryDate;3parcel.UpdatedAt = DateTime.UtcNow;
When a retry is allowed, three fields update on the parcel:
- Status returns to
InTransit, putting the parcel back on the delivery route - EstimatedDeliveryDate updates to the new date from the request
- UpdatedAt records the modification time
Note that DeliveryAttempts does not increment here. The counter incremented when the exception was reported. The retry simply moves the parcel back into the delivery pipeline.
Creating the Redelivery Tracking Event
csharp1var trackingEvent = new TrackingEvent2{3 ParcelId = parcel.Id,4 EventType = EventType.InTransit,5 Description = $"Redelivery scheduled. New estimated delivery: " +6 $"{request.NewEstimatedDeliveryDate:yyyy-MM-dd}",7 Timestamp = DateTime.UtcNow,8 LocationCity = parcel.RecipientAddress?.City,9 LocationState = parcel.RecipientAddress?.State,10 LocationCountry = parcel.RecipientAddress?.CountryCode11};
The tracking event records:
- EventType:
InTransitto match the new parcel status - Description: A clear message with the new estimated date, formatted as
yyyy-MM-ddfor readability - Location: The recipient's address, since redelivery targets the same destination
Anyone viewing the parcel's tracking history will see the full sequence: the original exception, the redelivery scheduling, and eventually the next delivery attempt.
The Full Retry Flow
Here is an example of a parcel going through exception and retry:
11. Parcel PKG-001 is InTransit, DeliveryAttempts = 0232. POST /api/parcels/1/exception { "reason": "RecipientUnavailable" }4 -> Status = Exception, DeliveryAttempts = 15 -> TrackingEvent: "Delivery exception: RecipientUnavailable"673. POST /api/parcels/1/retry { "newEstimatedDeliveryDate": "2025-02-18" }8 -> Status = InTransit, DeliveryAttempts = 1 (unchanged)9 -> TrackingEvent: "Redelivery scheduled. New estimated delivery: 2025-02-18"10114. POST /api/parcels/1/exception { "reason": "RecipientUnavailable" }12 -> Status = Exception, DeliveryAttempts = 213 -> TrackingEvent: "Delivery exception: RecipientUnavailable"14155. POST /api/parcels/1/retry { "newEstimatedDeliveryDate": "2025-02-20" }16 -> Status = InTransit, DeliveryAttempts = 2 (unchanged)17 -> TrackingEvent: "Redelivery scheduled. New estimated delivery: 2025-02-20"18196. POST /api/parcels/1/exception { "reason": "AddressNotFound" }20 -> Status = Exception, DeliveryAttempts = 321 -> TrackingEvent: "Delivery exception: AddressNotFound"22237. POST /api/parcels/1/retry { "newEstimatedDeliveryDate": "2025-02-22" }24 -> DeliveryAttempts (3) >= MaxDeliveryAttempts (3)25 -> Status = Returned26 -> TrackingEvent: "Parcel returned to sender after 3 failed delivery attempts"27 -> 400: "Maximum delivery attempts (3) reached."
After step 6, the parcel has exhausted all delivery attempts. The retry in step 7 triggers the auto-return logic.
Exception Monitoring Endpoint
Operations teams need to see all parcels currently in exception status. This is a simple filtered GET endpoint:
GET /api/parcels/exceptions
Implementation
csharp1[HttpGet("exceptions")]2public async Task<ActionResult<List<ParcelResponse>>> GetExceptionParcels()3{4 var parcels = await _context.Parcels5 .Include(p => p.ShipperAddress)6 .Include(p => p.RecipientAddress)7 .Where(p => p.Status == ParcelStatus.Exception)8 .OrderBy(p => p.UpdatedAt)9 .ToListAsync();1011 var response = parcels.Select(MapToResponse).ToList();12 return Ok(response);13}
The query:
- Includes both addresses so the response contains full address data
- Filters to only
Exceptionstatus parcels - Orders by
UpdatedAtso the oldest exceptions appear first, helping the team prioritize
Response Example
json1// GET /api/parcels/exceptions2// 200 OK3[4 {5 "id": 42,6 "trackingNumber": "PKG-20250215-X7K9M2",7 "status": "Exception",8 "deliveryAttempts": 1,9 "estimatedDeliveryDate": "2025-02-17T00:00:00Z",10 "updatedAt": "2025-02-15T14:30:00Z"11 },12 {13 "id": 58,14 "trackingNumber": "PKG-20250214-R3J8P1",15 "status": "Exception",16 "deliveryAttempts": 2,17 "estimatedDeliveryDate": "2025-02-16T00:00:00Z",18 "updatedAt": "2025-02-14T09:15:00Z"19 }20]
The response gives the operations team everything they need: tracking number, how many attempts have been made, and when the exception occurred.
Route Registration
Make sure the exceptions route is registered before the {id} route to avoid conflicts:
csharp1[ApiController]2[Route("api/parcels")]3public class ParcelsController : ControllerBase4{5 // This must come before {id} routes to avoid6 // "exceptions" being interpreted as an ID7 [HttpGet("exceptions")]8 public async Task<ActionResult<List<ParcelResponse>>> GetExceptionParcels()9 { ... }1011 [HttpGet("{id}")]12 public async Task<ActionResult<ParcelResponse>> GetById(Guid id)13 { ... }1415 [HttpPost("{id}/exception")]16 public async Task<ActionResult<ParcelResponse>> ReportException(...)17 { ... }1819 [HttpPost("{id}/retry")]20 public async Task<ActionResult<ParcelResponse>> RetryDelivery(...)21 { ... }22}
With attribute routing and integer route constraints, ASP.NET Core can distinguish between /api/parcels/exceptions (the literal string) and /api/parcels/42 (an integer ID). However, if the ID parameter is a string, you would need a route constraint like {id:int} to prevent ambiguity.
Date Validation
The NewEstimatedDeliveryDate should be in the future. Add a simple validation check:
csharp1if (request.NewEstimatedDeliveryDate <= DateTime.UtcNow)2{3 return BadRequest(new4 {5 message = "New estimated delivery date must be in the future"6 });7}
Place this check after the status validation but before modifying any data. This ensures the parcel is not changed if the date is invalid.
Testing the Retry Endpoint
Verify these scenarios:
-
Successful retry: Retry an
Exceptionparcel withDeliveryAttemptsbelow the limit. Verify status becomesInTransitandEstimatedDeliveryDateupdates. -
Max attempts reached: Retry a parcel with
DeliveryAttemptsequal toMaxDeliveryAttempts. Verify the parcel transitions toReturnedand a tracking event is created. -
Wrong status: Try to retry a parcel in
InTransitstatus. Verify a400response. -
Past date: Send a
NewEstimatedDeliveryDatein the past. Verify a400response. -
Full cycle: Walk through exception, retry, exception, retry, exception, and final retry to verify the complete lifecycle works end to end.
Testing the Monitoring Endpoint
-
No exceptions: When no parcels are in
Exceptionstatus, verify an empty array is returned. -
Multiple exceptions: Create several parcels in
Exceptionstatus. Verify all are returned, ordered byUpdatedAt. -
Mixed statuses: Ensure parcels in other statuses (
InTransit,Delivered) are not included in the results.
Key Takeaways
- The retry endpoint only accepts parcels in
Exceptionstatus - When
DeliveryAttemptsreaches the maximum (3), the parcel auto-transitions toReturned DeliveryAttemptsincrements on exception reporting, not on retry scheduling- The monitoring endpoint filters parcels by
Exceptionstatus and orders by oldest first - Route ordering matters when mixing literal segments and parameter segments
- Date validation prevents scheduling redelivery in the past
This completes the exception handling and retry delivery feature. The API now supports the full exception lifecycle from initial failure through retry attempts to automatic return.