7.1 IImageUrlFetcher

Add IImageUrlFetcher with HttpClient-backed implementation that enforces
UrlFetch.TimeoutSeconds, MaxRedirects, and MaxBytes. Surfaces failures as
ImageFetchException so the URL-ingestion endpoint can map them to 502.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 15:51:16 +02:00
parent d94009690c
commit 3bef27b286
5 changed files with 140 additions and 1 deletions

View File

@@ -2,6 +2,8 @@ using FrameProcessor.Configuration;
using FrameProcessor.ImagePipeline;
using FrameProcessor.Mqtt;
using FrameProcessor.Storage;
using FrameProcessor.UrlFetch;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
@@ -42,6 +44,21 @@ builder.Services.AddSingleton<ImageStore>();
builder.Services.AddSingleton<MqttPublisher>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<MqttPublisher>());
builder.Services.AddHttpClient<IImageUrlFetcher, ImageUrlFetcher>((sp, client) =>
{
var opts = sp.GetRequiredService<IOptions<UrlFetchOptions>>().Value;
client.Timeout = TimeSpan.FromSeconds(opts.TimeoutSeconds);
})
.ConfigurePrimaryHttpMessageHandler(sp =>
{
var opts = sp.GetRequiredService<IOptions<UrlFetchOptions>>().Value;
return new HttpClientHandler
{
AllowAutoRedirect = true,
MaxAutomaticRedirections = opts.MaxRedirects,
};
});
var app = builder.Build();
// Eagerly resolve FramesRegistry so an invalid frames.json fails startup fast.

View File

@@ -0,0 +1,20 @@
namespace FrameProcessor.UrlFetch;
/// <summary>
/// Fetches a remote image into an in-memory stream for the URL-ingestion endpoint
/// (SPEC.md §4.2). Enforces the guardrails from SPEC.md §8 — max response size,
/// timeout, and max redirect hops — surfacing failures as <see cref="ImageFetchException"/>
/// so callers can map them to <c>502 Bad Gateway</c>.
/// </summary>
public interface IImageUrlFetcher
{
/// <summary>
/// Fetch the image at <paramref name="uri"/>. The returned stream is positioned
/// at zero and owned by the caller.
/// </summary>
/// <exception cref="ImageFetchException">
/// Timeout, non-2xx status, response exceeding the configured maximum, or
/// excessive redirect hops.
/// </exception>
Task<Stream> FetchAsync(Uri uri, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,18 @@
namespace FrameProcessor.UrlFetch;
/// <summary>
/// Thrown by <see cref="IImageUrlFetcher"/> when a remote image fetch fails in a way
/// the caller should surface as <c>502 Bad Gateway</c> per SPEC.md §4.2 — timeout,
/// non-2xx status, response exceeding <c>UrlFetch.MaxBytes</c>, or redirect-loop /
/// excess redirect hops.
/// </summary>
public sealed class ImageFetchException : Exception
{
public ImageFetchException(string message) : base(message)
{
}
public ImageFetchException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,84 @@
using FrameProcessor.Configuration;
using Microsoft.Extensions.Options;
namespace FrameProcessor.UrlFetch;
/// <summary>
/// <see cref="IImageUrlFetcher"/> implementation backed by an <see cref="HttpClient"/>
/// from <see cref="IHttpClientFactory"/>. The client is configured with
/// <see cref="UrlFetchOptions.TimeoutSeconds"/>; redirect-hop enforcement lives on the
/// primary message handler (see <c>Program.cs</c>).
/// </summary>
public sealed class ImageUrlFetcher : IImageUrlFetcher
{
private const int CopyBufferSize = 81_920;
private readonly HttpClient _httpClient;
private readonly IOptions<UrlFetchOptions> _options;
public ImageUrlFetcher(HttpClient httpClient, IOptions<UrlFetchOptions> options)
{
_httpClient = httpClient;
_options = options;
}
public async Task<Stream> FetchAsync(Uri uri, CancellationToken cancellationToken)
{
HttpResponseMessage response;
try
{
response = await _httpClient
.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
throw new ImageFetchException($"Timed out fetching {uri}", ex);
}
catch (HttpRequestException ex)
{
// HttpClient surfaces redirect-limit overflows here as well as transport errors.
throw new ImageFetchException($"Request to {uri} failed: {ex.Message}", ex);
}
using (response)
{
if (!response.IsSuccessStatusCode)
{
throw new ImageFetchException(
$"Request to {uri} returned HTTP {(int)response.StatusCode}");
}
var maxBytes = _options.Value.MaxBytes;
if (response.Content.Headers.ContentLength is long advertised && advertised > maxBytes)
{
throw new ImageFetchException(
$"Response Content-Length {advertised} exceeds maximum {maxBytes} bytes");
}
await using var source = await response.Content
.ReadAsStreamAsync(cancellationToken)
.ConfigureAwait(false);
var buffer = new MemoryStream();
var chunk = new byte[CopyBufferSize];
long total = 0;
int read;
while ((read = await source.ReadAsync(chunk.AsMemory(0, chunk.Length), cancellationToken)
.ConfigureAwait(false)) > 0)
{
total += read;
if (total > maxBytes)
{
buffer.Dispose();
throw new ImageFetchException(
$"Response exceeded maximum size of {maxBytes} bytes");
}
buffer.Write(chunk, 0, read);
}
buffer.Position = 0;
return buffer;
}
}
}