20 minlesson

Retry Logic & Exception Monitoring

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:

json
1{
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

csharp
1public class RetryDeliveryRequest
2{
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:

csharp
1private 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:

csharp
1[HttpPost("{id}/retry")]
2public async Task<ActionResult<ParcelResponse>> RetryDelivery(
3 Guid id,
4 RetryDeliveryRequest request)
5{
6 var parcel = await _context.Parcels
7 .Include(p => p.RecipientAddress)
8 .FirstOrDefaultAsync(p => p.Id == id);
9
10 if (parcel == null)
11 return NotFound(new { message = "Parcel not found" });
12
13 if (parcel.Status != ParcelStatus.Exception)
14 {
15 return BadRequest(new
16 {
17 message = $"Cannot retry delivery for parcel in {parcel.Status} status. " +
18 "Only parcels in Exception status can be retried."
19 });
20 }
21
22 if (parcel.DeliveryAttempts >= MaxDeliveryAttempts)
23 {
24 parcel.Status = ParcelStatus.Returned;
25 parcel.UpdatedAt = DateTime.UtcNow;
26
27 var returnEvent = new TrackingEvent
28 {
29 ParcelId = parcel.Id,
30 EventType = EventType.Returned,
31 Description = $"Parcel returned to sender after {MaxDeliveryAttempts} " +
32 "failed delivery attempts",
33 Timestamp = DateTime.UtcNow
34 };
35
36 _context.TrackingEvents.Add(returnEvent);
37 await _context.SaveChangesAsync();
38
39 return BadRequest(new
40 {
41 message = $"Maximum delivery attempts ({MaxDeliveryAttempts}) reached. " +
42 "Parcel has been returned to sender.",
43 parcel = MapToResponse(parcel)
44 });
45 }
46
47 parcel.Status = ParcelStatus.InTransit;
48 parcel.EstimatedDeliveryDate = request.NewEstimatedDeliveryDate;
49 parcel.UpdatedAt = DateTime.UtcNow;
50
51 var trackingEvent = new TrackingEvent
52 {
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?.CountryCode
61 };
62
63 _context.TrackingEvents.Add(trackingEvent);
64 await _context.SaveChangesAsync();
65
66 return Ok(MapToResponse(parcel));
67}

Let us examine each section of this action.

Status Validation

csharp
1if (parcel.Status != ParcelStatus.Exception)
2{
3 return BadRequest(new
4 {
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

csharp
1if (parcel.DeliveryAttempts >= MaxDeliveryAttempts)
2{
3 parcel.Status = ParcelStatus.Returned;
4 parcel.UpdatedAt = DateTime.UtcNow;
5
6 var returnEvent = new TrackingEvent
7 {
8 ParcelId = parcel.Id,
9 EventType = EventType.Returned,
10 Description = $"Parcel returned to sender after {MaxDeliveryAttempts} " +
11 "failed delivery attempts",
12 Timestamp = DateTime.UtcNow
13 };
14
15 _context.TrackingEvents.Add(returnEvent);
16 await _context.SaveChangesAsync();
17
18 return BadRequest(new
19 {
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:

  1. Transitions the parcel to Returned status
  2. Creates a Returned tracking event explaining why
  3. Saves both changes
  4. Returns a 400 Bad Request with 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

csharp
1parcel.Status = ParcelStatus.InTransit;
2parcel.EstimatedDeliveryDate = request.NewEstimatedDeliveryDate;
3parcel.UpdatedAt = DateTime.UtcNow;

When a retry is allowed, three fields update on the parcel:

  1. Status returns to InTransit, putting the parcel back on the delivery route
  2. EstimatedDeliveryDate updates to the new date from the request
  3. 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

csharp
1var trackingEvent = new TrackingEvent
2{
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?.CountryCode
11};

The tracking event records:

  • EventType: InTransit to match the new parcel status
  • Description: A clear message with the new estimated date, formatted as yyyy-MM-dd for 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 = 0
2
32. POST /api/parcels/1/exception { "reason": "RecipientUnavailable" }
4 -> Status = Exception, DeliveryAttempts = 1
5 -> TrackingEvent: "Delivery exception: RecipientUnavailable"
6
73. 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"
10
114. POST /api/parcels/1/exception { "reason": "RecipientUnavailable" }
12 -> Status = Exception, DeliveryAttempts = 2
13 -> TrackingEvent: "Delivery exception: RecipientUnavailable"
14
155. 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"
18
196. POST /api/parcels/1/exception { "reason": "AddressNotFound" }
20 -> Status = Exception, DeliveryAttempts = 3
21 -> TrackingEvent: "Delivery exception: AddressNotFound"
22
237. POST /api/parcels/1/retry { "newEstimatedDeliveryDate": "2025-02-22" }
24 -> DeliveryAttempts (3) >= MaxDeliveryAttempts (3)
25 -> Status = Returned
26 -> 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

csharp
1[HttpGet("exceptions")]
2public async Task<ActionResult<List<ParcelResponse>>> GetExceptionParcels()
3{
4 var parcels = await _context.Parcels
5 .Include(p => p.ShipperAddress)
6 .Include(p => p.RecipientAddress)
7 .Where(p => p.Status == ParcelStatus.Exception)
8 .OrderBy(p => p.UpdatedAt)
9 .ToListAsync();
10
11 var response = parcels.Select(MapToResponse).ToList();
12 return Ok(response);
13}

The query:

  1. Includes both addresses so the response contains full address data
  2. Filters to only Exception status parcels
  3. Orders by UpdatedAt so the oldest exceptions appear first, helping the team prioritize

Response Example

json
1// GET /api/parcels/exceptions
2// 200 OK
3[
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:

csharp
1[ApiController]
2[Route("api/parcels")]
3public class ParcelsController : ControllerBase
4{
5 // This must come before {id} routes to avoid
6 // "exceptions" being interpreted as an ID
7 [HttpGet("exceptions")]
8 public async Task<ActionResult<List<ParcelResponse>>> GetExceptionParcels()
9 { ... }
10
11 [HttpGet("{id}")]
12 public async Task<ActionResult<ParcelResponse>> GetById(Guid id)
13 { ... }
14
15 [HttpPost("{id}/exception")]
16 public async Task<ActionResult<ParcelResponse>> ReportException(...)
17 { ... }
18
19 [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:

csharp
1if (request.NewEstimatedDeliveryDate <= DateTime.UtcNow)
2{
3 return BadRequest(new
4 {
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:

  1. Successful retry: Retry an Exception parcel with DeliveryAttempts below the limit. Verify status becomes InTransit and EstimatedDeliveryDate updates.

  2. Max attempts reached: Retry a parcel with DeliveryAttempts equal to MaxDeliveryAttempts. Verify the parcel transitions to Returned and a tracking event is created.

  3. Wrong status: Try to retry a parcel in InTransit status. Verify a 400 response.

  4. Past date: Send a NewEstimatedDeliveryDate in the past. Verify a 400 response.

  5. 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

  1. No exceptions: When no parcels are in Exception status, verify an empty array is returned.

  2. Multiple exceptions: Create several parcels in Exception status. Verify all are returned, ordered by UpdatedAt.

  3. Mixed statuses: Ensure parcels in other statuses (InTransit, Delivered) are not included in the results.

Key Takeaways

  1. The retry endpoint only accepts parcels in Exception status
  2. When DeliveryAttempts reaches the maximum (3), the parcel auto-transitions to Returned
  3. DeliveryAttempts increments on exception reporting, not on retry scheduling
  4. The monitoring endpoint filters parcels by Exception status and orders by oldest first
  5. Route ordering matters when mixing literal segments and parameter segments
  6. 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.