18 minlesson

Exception Reporting Endpoint

Exception Reporting Endpoint

When a delivery attempt fails, the carrier system needs to report the exception through the API. This presentation walks through building the endpoint that transitions a parcel to Exception status, records the reason, and creates a tracking event.

API Design

The exception reporting endpoint uses POST because it creates a new state (an exception) on an existing resource:

POST /api/parcels/{id}/exception

The request body contains the reason for the exception:

json
1{
2 "reason": "RecipientUnavailable"
3}

A successful response returns the updated parcel with a 200 OK status code.

The ExceptionReason Enum

We model exception reasons as a C# enum with JsonStringEnumConverter so the API accepts and returns string values instead of integers:

csharp
1using System.Text.Json.Serialization;
2
3[JsonConverter(typeof(JsonStringEnumConverter))]
4public enum ExceptionReason
5{
6 AddressNotFound,
7 RecipientUnavailable,
8 DamagedPackage,
9 WeatherDelay,
10 CustomsHold,
11 RefusedByRecipient
12}

The JsonStringEnumConverter attribute means clients send "RecipientUnavailable" instead of 1. This makes the API self-documenting and less error-prone.

The Request DTO

The request body is simple, containing only the exception reason:

csharp
1public class ReportExceptionRequest
2{
3 public ExceptionReason Reason { get; set; }
4}

ASP.NET Core model binding will automatically deserialize the JSON string into the enum value. If the client sends an invalid reason string, the framework returns a 400 response before your code runs.

Validating the Current Status

Not every parcel can receive an exception. Only parcels that are actively being delivered should transition to Exception. We validate that the parcel is in an eligible status before proceeding:

csharp
1private static readonly ParcelStatus[] ExceptionEligibleStatuses =
2{
3 ParcelStatus.InTransit,
4 ParcelStatus.OutForDelivery
5};

If the parcel is in LabelCreated, Delivered, Returned, or already in Exception, reporting a new exception does not make sense. The endpoint returns a 400 Bad Request with a message explaining the invalid transition.

The Controller Action

Here is the complete controller action for reporting a delivery exception:

csharp
1[HttpPost("{id}/exception")]
2public async Task<ActionResult<ParcelResponse>> ReportException(
3 Guid id,
4 ReportExceptionRequest request)
5{
6 var parcel = await _context.Parcels.FindAsync(id);
7
8 if (parcel == null)
9 return NotFound(new { message = "Parcel not found" });
10
11 if (!ExceptionEligibleStatuses.Contains(parcel.Status))
12 {
13 return BadRequest(new
14 {
15 message = $"Cannot report exception for parcel in {parcel.Status} status"
16 });
17 }
18
19 parcel.Status = ParcelStatus.Exception;
20 parcel.DeliveryAttempts++;
21 parcel.UpdatedAt = DateTime.UtcNow;
22
23 var trackingEvent = new TrackingEvent
24 {
25 ParcelId = parcel.Id,
26 EventType = EventType.DeliveryAttempted,
27 Description = $"Delivery exception: {request.Reason}",
28 DelayReason = request.Reason.ToString(),
29 Timestamp = DateTime.UtcNow,
30 LocationCity = parcel.RecipientAddress?.City,
31 LocationState = parcel.RecipientAddress?.State,
32 LocationCountry = parcel.RecipientAddress?.CountryCode
33 };
34
35 _context.TrackingEvents.Add(trackingEvent);
36 await _context.SaveChangesAsync();
37
38 return Ok(MapToResponse(parcel));
39}

Let us break down what happens in this action step by step.

Step 1: Find the Parcel

csharp
1var parcel = await _context.Parcels.FindAsync(id);
2
3if (parcel == null)
4 return NotFound(new { message = "Parcel not found" });

The FindAsync method uses the primary key to look up the parcel. If the parcel does not exist, we return 404 Not Found immediately.

Step 2: Validate the Status

csharp
1if (!ExceptionEligibleStatuses.Contains(parcel.Status))
2{
3 return BadRequest(new
4 {
5 message = $"Cannot report exception for parcel in {parcel.Status} status"
6 });
7}

The status check prevents invalid transitions. A parcel that is already Delivered or Returned cannot receive a new exception. Including the current status in the error message helps the client understand why the request failed.

Step 3: Update the Parcel

csharp
1parcel.Status = ParcelStatus.Exception;
2parcel.DeliveryAttempts++;
3parcel.UpdatedAt = DateTime.UtcNow;

Three fields change on the parcel:

  1. Status transitions to Exception
  2. DeliveryAttempts increments by one to track the failed attempt count
  3. UpdatedAt records when this change occurred

The DeliveryAttempts counter is critical for the retry logic that we build in the next presentation. Once this counter reaches 3, no more retries are allowed.

Step 4: Create the Tracking Event

csharp
1var trackingEvent = new TrackingEvent
2{
3 ParcelId = parcel.Id,
4 EventType = EventType.DeliveryAttempted,
5 Description = $"Delivery exception: {request.Reason}",
6 DelayReason = request.Reason.ToString(),
7 Timestamp = DateTime.UtcNow,
8 LocationCity = parcel.RecipientAddress?.City,
9 LocationState = parcel.RecipientAddress?.State,
10 LocationCountry = parcel.RecipientAddress?.CountryCode
11};
12
13_context.TrackingEvents.Add(trackingEvent);

The tracking event captures:

  • EventType: DeliveryAttempted indicates a delivery was tried but failed
  • Description: A human-readable message including the reason
  • DelayReason: The exception reason stored as a string for querying and filtering
  • Location: The recipient's address, since that is where the delivery attempt happened
  • Timestamp: When the exception occurred

Step 5: Save and Respond

csharp
1await _context.SaveChangesAsync();
2return Ok(MapToResponse(parcel));

Both the parcel update and the new tracking event are saved in a single SaveChangesAsync call. EF Core wraps this in a transaction, so either both changes succeed or neither does. The response returns the updated parcel data.

Loading the Recipient Address

The tracking event uses the recipient's address for location data. If the parcel was loaded without its navigation properties, those fields would be null. To ensure the address is available, load it with Include:

csharp
1var parcel = await _context.Parcels
2 .Include(p => p.RecipientAddress)
3 .FirstOrDefaultAsync(p => p.Id == id);

This eager-loads the recipient address in the same database query as the parcel, avoiding a second round-trip.

Error Response Examples

When the parcel does not exist:

json
1// POST /api/parcels/999/exception
2// 404 Not Found
3{
4 "message": "Parcel not found"
5}

When the parcel is in an invalid status:

json
1// POST /api/parcels/1/exception (parcel is Delivered)
2// 400 Bad Request
3{
4 "message": "Cannot report exception for parcel in Delivered status"
5}

When the request contains an invalid reason:

json
1// POST /api/parcels/1/exception
2// Body: { "reason": "InvalidReason" }
3// 400 Bad Request (automatic model binding error)
4{
5 "errors": {
6 "reason": ["The value 'InvalidReason' is not valid."]
7 }
8}

Testing the Endpoint

Verify the endpoint works correctly with these test scenarios:

  1. Happy path: Report an exception on an InTransit parcel. Verify the status changes to Exception, DeliveryAttempts increments, and a tracking event is created.

  2. Invalid status: Try to report an exception on a Delivered parcel. Verify a 400 response.

  3. Not found: Report an exception on a non-existent parcel ID. Verify a 404 response.

  4. Invalid reason: Send an invalid enum value. Verify the framework returns a 400 response.

  5. Multiple exceptions: Report two exceptions on the same parcel (with a retry in between). Verify DeliveryAttempts reaches 2.

Complete Request/Response Example

Request:

http
1POST /api/parcels/42/exception
2Content-Type: application/json
3
4{
5 "reason": "RecipientUnavailable"
6}

Response:

json
1// 200 OK
2{
3 "id": 42,
4 "trackingNumber": "PKG-20250215-X7K9M2",
5 "status": "Exception",
6 "serviceType": "Express",
7 "deliveryAttempts": 1,
8 "estimatedDeliveryDate": "2025-02-17T00:00:00Z",
9 "updatedAt": "2025-02-15T14:30:00Z"
10}

The response confirms the status changed to Exception and the delivery attempts count is now 1.

Key Takeaways

  1. The exception endpoint validates the parcel exists and is in an eligible status before transitioning
  2. ExceptionReason is an enum with JsonStringEnumConverter for readable API values
  3. DeliveryAttempts increments on every exception report to track the failure count
  4. A DeliveryAttempted tracking event preserves the exception reason and location
  5. EF Core's SaveChangesAsync provides transactional consistency for the parcel update and tracking event

Next, we will build the retry endpoint that moves parcels from Exception back to InTransit and enforces the maximum attempt limit.