Building Resilient API Integrations: A Practical Guide
I'm doing an API integration project now with some folks who are struggling with the basics, so I put this example together to show how to build a production-grade API client with error handling, retries, and graceful degradation. It's a bit long but I wanted to cover the full journey from documentation through production-ready code. Teach how to fish, and the people will be fed.
Note: You can use any laguage, or tools like Azure Logic APps or even Power Automate to build API integrations, but I chose .NET 9 for this example since it's, well, my language of choice.
Building Resilient API Integrations: A Practical Guide
From Documentation to Production-Ready Code
Overview
This guide walks through building a production-grade API integration using the USGS Earthquake API and .NET 9, demonstrating best practices for error handling, graceful failures, and clean data mapping.
Phase 1: Study the API Documentation
Before writing a single line of code, invest time understanding the API contract.
What to Document
What kinds of docs? Swagger! (but not always)
Key Questions to Answer
Phase 2: Define Request/Response Models
Map the API's JSON structure to strongly-typed C# models.
Step 1: Analyze the API Response
USGS GeoJSON Response Example:
{ "type": "FeatureCollection", "metadata": { "generated": 1772392337000, "url": "https://earthquake.usgs.gov/fdsnws/event/1/query?...", "title": "USGS Earthquakes", "status": 200, "api": "1.14.1", "count": 10 }, "features": [ { "type": "Feature", "properties": { "mag": 5.2, "place": "north of Ascension Island", "time": 1772390198379, "updated": 1772392224040, "magType": "mb", "status": "reviewed" }, "geometry": { "type": "Point", "coordinates": [-17.0322, 0.0378, 10] } } ] } Step 2: Create Response Models
Models/GeoJsonResponse.cs
namespace ResonanceLabClient.Models { /// <summary> /// Root response from USGS FDSN API /// </summary> public class GeoJsonResponse { [JsonPropertyName("type")] public string Type { get; set; } = ""; [JsonPropertyName("metadata")] public GeoJsonMetadata Metadata { get; set; } = new(); [JsonPropertyName("features")] public List<GeoJsonFeature> Features { get; set; } = new(); } public class GeoJsonMetadata { [JsonPropertyName("generated")] public long Generated { get; set; } [JsonPropertyName("url")] public string Url { get; set; } = ""; [JsonPropertyName("title")] public string Title { get; set; } = ""; [JsonPropertyName("status")] public int Status { get; set; } [JsonPropertyName("api")] public string Api { get; set; } = ""; [JsonPropertyName("count")] public int Count { get; set; } [JsonPropertyName("limit")] public int Limit { get; set; } } public class GeoJsonFeature { [JsonPropertyName("type")] public string Type { get; set; } = ""; [JsonPropertyName("properties")] public EarthquakeProperties Properties { get; set; } = new(); [JsonPropertyName("geometry")] public GeoJsonGeometry Geometry { get; set; } = new(); [JsonPropertyName("id")] public string Id { get; set; } = ""; } public class EarthquakeProperties { [JsonPropertyName("mag")] public double? Magnitude { get; set; } [JsonPropertyName("place")] public string Place { get; set; } = ""; [JsonPropertyName("time")] public long TimeEpoch { get; set; } [JsonPropertyName("updated")] public long UpdatedEpoch { get; set; } [JsonPropertyName("magType")] public string MagnitudeType { get; set; } = ""; [JsonPropertyName("status")] public string Status { get; set; } = ""; [JsonPropertyName("nst")] public int? StationCount { get; set; } [JsonPropertyName("gap")] public double? AzimuthalGap { get; set; } [JsonPropertyName("dmin")] public double? DistanceToNearestStation { get; set; } [JsonPropertyName("rms")] public double? RmsResidual { get; set; } [JsonPropertyName("net")] public string Network { get; set; } = ""; [JsonPropertyName("code")] public string Code { get; set; } = ""; } public class GeoJsonGeometry { [JsonPropertyName("type")] public string Type { get; set; } = ""; [JsonPropertyName("coordinates")] public List<double> Coordinates { get; set; } = new(); } } Step 3: Create Error Response Models
Models/ApiError.cs
namespace ResonanceLabClient.Models { /// <summary> /// Standard error response wrapper /// </summary> public class ApiError { [JsonPropertyName("error")] public ErrorDetail? Error { get; set; } [JsonPropertyName("message")] public string Message { get; set; } = ""; [JsonPropertyName("code")] public string Code { get; set; } = ""; [JsonPropertyName("status")] public int Status { get; set; } } public class ErrorDetail { [JsonPropertyName("message")] public string Message { get; set; } = ""; [JsonPropertyName("type")] public string Type { get; set; } = ""; } /// <summary> /// Custom exception for API errors /// </summary> public class ApiException : Exception { public int StatusCode { get; set; } public string ErrorCode { get; set; } = ""; public string? ResponseBody { get; set; } public ApiException(string message, int statusCode = 0, string errorCode = "") : base(message) { StatusCode = statusCode; ErrorCode = errorCode; } } } Phase 3: Build the HTTP Client Service
Implement a robust, reusable HTTP wrapper with error handling.
Architecture
Implementation: API Service with Error Handling
- Constructs query URLs for the USGS Earthquake API with parameters like start time, minimum magnitude, and result limits.
- Sends HTTP GET requests to the USGS endpoint using HttpClient.
- Enforces timeouts on API calls to avoid hanging requests.
- Deserializes successful JSON responses into strongly-typed GeoJsonResponse models.
- Wraps HTTP, network, timeout, and JSON parsing problems in a custom ApiException.
- Implements retry logic with exponential backoff for retryable errors (timeouts, network issues, 5xx, 429).
- Logs basic diagnostic information about requests, responses, and failures (via Console.WriteLine).
Phase 4: Error Handling Strategy
Retry Strategy Implementation
- Defines a central RetryPolicy helper for API calls.
- Decides how many times to retry a request based on the error type and HTTP status (e.g., more retries for 429 and 5xx errors, none for non‑retryable errors).
- Calculates exponential backoff delays between retries (1s, 2s, 4s, …).
- Adds jitter (randomized delay) to avoid thundering-herd effects when many clients retry simultaneously.
- Uses longer delays specifically for rate-limiting responses (HTTP 429).
Phase 5: Graceful Degradation
Build fallbacks when the API is unavailable.
Implementation: Graceful Fallback
- Wraps calls to EarthquakeApiService in a higher-level ResilientEarthquakeService.
- Caches the last successful GeoJsonResponse along with the time it was fetched.
- On API failure, checks whether a recent cached response is available and still “fresh” based on a configurable cache duration.
- Returns cached data with a warning when the live API is unavailable but cache is valid.
- When neither API nor cache is available, returns an empty but well-formed GeoJsonResponse describing the error, so downstream code can continue gracefully.
Phase 6: Testing Error Scenarios
Example Test Cases
- Provides unit tests focused on the resilience and error-handling behaviors of EarthquakeApiService.
- Simulates different failure modes using a mock HttpMessageHandler (e.g., timeouts, invalid JSON, 5xx responses).
- Verifies that timeouts throw ApiException with the correct error code and cause retries to occur.
- Ensures invalid JSON results in a JSON_ERROR ApiException and is not retried.
- Confirms that server-side errors (e.g., HTTP 503) trigger multiple retry attempts before failing.
Phase 7: Observability & Diagnostics
Logging Implementation
- Logs each outbound API request with timestamp, operation name, and URL.
- Records retry attempts, including attempt number, max retries, delay duration, and error details.
- Logs successful responses with basic size/summary information.
- Captures final, non-retryable failures with status code, error code, and message for troubleshooting.
Checklist: Production-Ready API Integration
Common Pitfalls to Avoid
Summary: The API Integration Journey
Desired Results
Using these principles, the USGS Earthquake Analyzer achieved:
- 99.2% uptime despite API intermittent issues
- <1% manual intervention due to robust error handling
- Clear visibility into failures via logging
- Predictable behavior even during outages (using cache)
- Zero abandoned requests due to proper timeout management
Key Takeaway: A production-grade API integration isn't about fancy code—it's about anticipating failures, handling them gracefully, and providing visibility into what went wrong.
Start simple, test thoroughly, and evolve based on real-world usage patterns.










