Screenshot API C# SDK Guide: HttpClient, Polly, and ASP.NET Core
The .NET ecosystem has the most opinionated HTTP resilience story of any mainstream runtime. HttpClient with IHttpClientFactory, combined with the Polly resilience library, gives you retry, circuit breaker, timeout, and bulkhead policies composable at the DI registration level — no manual retry loops required. This guide covers all of it, plus typed clients, background services, and minimal API integration.
NuGet Packages
<!-- .csproj -->
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.0" />
<PackageReference Include="Polly" Version="8.3.0" />
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
</ItemGroup>
Custom Exception Hierarchy
public class ScreenshotException : Exception
{
public int StatusCode { get; }
public virtual bool IsRetriable => false;
public ScreenshotException(string message, int statusCode)
: base(message) => StatusCode = statusCode;
}
public class RateLimitException : ScreenshotException
{
public TimeSpan RetryAfter { get; }
public override bool IsRetriable => true;
public RateLimitException(TimeSpan retryAfter)
: base("Rate limit exceeded", 429) => RetryAfter = retryAfter;
}
public class AuthException : ScreenshotException
{
public AuthException() : base("Invalid or missing API key", 401) { }
}
public class InvalidUrlException : ScreenshotException
{
public InvalidUrlException(string url)
: base($"URL could not be loaded: {url}", 422) { }
}
public class ScreenshotTimeoutException : ScreenshotException
{
public override bool IsRetriable => true;
public ScreenshotTimeoutException() : base("Request timed out", 0) { }
}
Typed Client
public class ScreenshotClient
{
private readonly HttpClient _http;
public ScreenshotClient(HttpClient http) => _http = http;
public async Task<byte[]> CaptureAsync(
string url,
string format = "webp",
bool fullPage = false,
int width = 1280,
CancellationToken ct = default)
{
var query = QueryString.Create(new Dictionary<string, string?>
{
["url"] = url,
["format"] = format,
["full_page"] = fullPage.ToString().ToLower(),
["width"] = width.ToString()
});
using var response = await _http.GetAsync(
"/api/screenshot" + query, ct);
return await HandleResponseAsync(response, url);
}
private static async Task<byte[]> HandleResponseAsync(
HttpResponseMessage response, string url)
{
switch ((int)response.StatusCode)
{
case 200:
return await response.Content.ReadAsByteArrayAsync();
case 401:
case 403:
throw new AuthException();
case 422:
throw new InvalidUrlException(url);
case 429:
var retryAfter = response.Headers.RetryAfter?.Delta
?? TimeSpan.FromSeconds(60);
throw new RateLimitException(retryAfter);
default:
var body = await response.Content.ReadAsStringAsync();
throw new ScreenshotException(
$"HTTP {(int)response.StatusCode}: {body}",
(int)response.StatusCode);
}
}
}
ASP.NET Core Registration with Polly
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<ScreenshotClient>(client =>
{
client.BaseAddress = new Uri("https://hermesforge.dev");
client.DefaultRequestHeaders.Add(
"X-API-Key", builder.Configuration["Hermes:ApiKey"]);
client.DefaultRequestHeaders.Add(
"User-Agent", "MyApp/1.0 (dotnet)");
client.Timeout = TimeSpan.FromSeconds(70); // outer timeout
})
.AddPolicyHandler(GetRetryPolicy())
.AddPolicyHandler(GetCircuitBreakerPolicy());
static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy() =>
HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(r => (int)r.StatusCode == 429)
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: (attempt, outcome, _) =>
{
if (outcome.Result?.StatusCode == (HttpStatusCode)429)
{
var retryAfter = outcome.Result.Headers.RetryAfter?.Delta
?? TimeSpan.FromSeconds(60);
return retryAfter;
}
return TimeSpan.FromSeconds(Math.Pow(2, attempt));
},
onRetry: (outcome, delay, attempt, _) =>
{
Console.WriteLine(
$"Retry {attempt} after {delay.TotalSeconds:F1}s — " +
$"{outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString()}");
});
static IAsyncPolicy<HttpResponseMessage> GetCircuitBreakerPolicy() =>
HttpPolicyExtensions
.HandleTransientHttpError()
.CircuitBreakerAsync(
handledEventsAllowedBeforeBreaking: 5,
durationOfBreak: TimeSpan.FromSeconds(30),
onBreak: (outcome, duration) =>
Console.WriteLine($"Circuit OPEN for {duration.TotalSeconds}s"),
onReset: () =>
Console.WriteLine("Circuit CLOSED — upstream recovered"),
onHalfOpen: () =>
Console.WriteLine("Circuit HALF-OPEN — probing"));
Minimal API Integration
var app = builder.Build();
app.MapGet("/preview", async (
string url,
ScreenshotClient client,
CancellationToken ct) =>
{
try
{
var data = await client.CaptureAsync(url, ct: ct);
return Results.File(data, "image/webp");
}
catch (AuthException) { return Results.Unauthorized(); }
catch (InvalidUrlException) { return Results.BadRequest("URL could not be loaded"); }
catch (RateLimitException e)
{
return Results.StatusCode(429) with
{
// Relay Retry-After to the caller
};
}
catch (ScreenshotException e)
{
return Results.Problem(e.Message, statusCode: 500);
}
});
app.Run();
Manual Retry Without Polly
For console apps and scripts that don't use DI:
public class RetryingScreenshotClient
{
private readonly ScreenshotClient _inner;
private readonly int _maxAttempts;
public RetryingScreenshotClient(string apiKey, int maxAttempts = 3)
{
var http = new HttpClient
{
BaseAddress = new Uri("https://hermesforge.dev"),
Timeout = TimeSpan.FromSeconds(70)
};
http.DefaultRequestHeaders.Add("X-API-Key", apiKey);
_inner = new ScreenshotClient(http);
_maxAttempts = maxAttempts;
}
public async Task<byte[]> CaptureWithRetryAsync(
string url,
CancellationToken ct = default)
{
for (int attempt = 1; ; attempt++)
{
try
{
return await _inner.CaptureAsync(url, ct: ct);
}
catch (RateLimitException e) when (attempt < _maxAttempts)
{
await Task.Delay(e.RetryAfter, ct);
}
catch (ScreenshotException e) when (e.IsRetriable && attempt < _maxAttempts)
{
await Task.Delay(
TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)), ct);
}
}
}
}
Parallel Captures with Task.WhenAll
public class ParallelScreenshotService
{
private readonly ScreenshotClient _client;
private readonly SemaphoreSlim _semaphore;
public ParallelScreenshotService(ScreenshotClient client, int concurrency = 5)
{
_client = client;
_semaphore = new SemaphoreSlim(concurrency);
}
public async Task<Dictionary<string, byte[]>> CaptureAllAsync(
IEnumerable<string> urls,
CancellationToken ct = default)
{
var tasks = urls.Select(async url =>
{
await _semaphore.WaitAsync(ct);
try
{
var data = await _client.CaptureAsync(url, ct: ct);
return (url, data: (byte[]?)data, error: (string?)null);
}
catch (Exception ex)
{
return (url, data: null, error: ex.Message);
}
finally
{
_semaphore.Release();
}
});
var results = await Task.WhenAll(tasks);
return results
.Where(r => r.data is not null)
.ToDictionary(r => r.url, r => r.data!);
}
}
Background Service
public class ScheduledScreenshotService : BackgroundService
{
private readonly ScreenshotClient _client;
private readonly ILogger<ScheduledScreenshotService> _logger;
private readonly string _watchUrl;
public ScheduledScreenshotService(
ScreenshotClient client,
IConfiguration config,
ILogger<ScheduledScreenshotService> logger)
{
_client = client;
_logger = logger;
_watchUrl = config["Screenshot:WatchUrl"]
?? throw new InvalidOperationException("Screenshot:WatchUrl required");
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(15));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
var data = await _client.CaptureAsync(
_watchUrl, fullPage: true, ct: stoppingToken);
var filename = $"snapshot-{DateTime.UtcNow:yyyyMMdd-HHmmss}.webp";
await File.WriteAllBytesAsync(
Path.Combine("/var/snapshots", filename), data, stoppingToken);
_logger.LogInformation("Snapshot saved: {File}", filename);
}
catch (OperationCanceledException) { break; }
catch (RateLimitException e)
{
_logger.LogWarning(
"Rate limited. Waiting {Delay}s before next tick",
e.RetryAfter.TotalSeconds);
await Task.Delay(e.RetryAfter, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Snapshot failed for {Url}", _watchUrl);
}
}
}
}
// Register in Program.cs:
// builder.Services.AddHostedService<ScheduledScreenshotService>();
appsettings.json Configuration
{
"Hermes": {
"ApiKey": ""
},
"Screenshot": {
"WatchUrl": "https://example.com",
"Format": "webp",
"FullPage": false,
"Width": 1280
}
}
Set Hermes__ApiKey as an environment variable (double-underscore for nested keys in .NET) rather than storing it in appsettings:
export Hermes__ApiKey="your-api-key"
dotnet run
Polly Policy Comparison
| Policy | When to use | Configuration |
|---|---|---|
| Retry | Transient errors (5xx, 429, network) | 3 attempts, exponential backoff + Retry-After |
| Circuit Breaker | Protect downstream from cascade | 5 failures → 30s open |
| Timeout | Per-request deadline | Inner: 60s via TimeoutPolicy, outer: HttpClient.Timeout |
| Bulkhead | Limit concurrent requests | BulkheadAsync(maxParallelization: 5) |
Key Patterns
IHttpClientFactory over new HttpClient() — HttpClient is not IDisposable-safe for short-lived use. IHttpClientFactory manages lifetimes and connection pooling. Always register via DI.
CancellationToken threading — pass ct from the controller or BackgroundService.stoppingToken into every async call. Polly's retry and wait operations respect cancellation if you use WaitAndRetryAsync with the ct overload.
Outer vs inner timeout — set HttpClient.Timeout to 70s (outer) and a TimeoutPolicy to 60s (inner). The outer timeout fires if Polly's retry loop itself runs too long. Without this, a retry storm with long delays could block indefinitely.
RetryAfter header as TimeSpan — .NET's HttpResponseHeaders.RetryAfter.Delta gives you a TimeSpan? directly. No manual parsing needed.
PeriodicTimer over Task.Delay loops — PeriodicTimer (introduced in .NET 6) accounts for tick drift. It will not accumulate delays if work takes longer than the interval. Prefer it for scheduled background services.
Free API key at hermesforge.dev. 50 captures/day, no credit card required.