Inspectable download responses via IDownloadClient and DownloadResult
Background
IRestClient.DownloadStreamAsync returns Task<Stream?>. On failure it either throws (when ThrowOnAnyError = true) or returns null — in both cases the caller loses access to the RestResponse: status code, headers, response body, error details. This makes downloads materially less usable than ExecuteAsync, where the full RestResponse is always available for inspection.
This has been a recurring pain point. The most explicit attempt to address it was #2128, which added an Action<RestResponse> error-handler overload. That PR is being closed in favour of this design proposal because the callback shape isn't the right primitive — see below.
Goals
- Give callers the same inspectability for downloads that
ExecuteAsyncgives for normal requests: fullRestResponseaccess on both success and failure. - Keep the existing
DownloadStreamAsyncAPI working unchanged. No deprecation, no break for current consumers orIRestClientimplementers. - Avoid expanding
IRestClientfurther — the interface is intentionally narrow, andDownloadStreamAsyncarguably should not have been on it in the first place. - Preserve streaming semantics (
HttpCompletionOption.ResponseHeadersRead, no buffered byte materialisation on success).
Proposal
New result type
public sealed record DownloadResult(Stream? Stream, RestResponse Response) {
public bool IsSuccess => Stream != null && Response.IsSuccessStatusCode;
}Response is always present and inspectable: StatusCode, Headers, ContentHeaders (including Content-Type, Content-Disposition/filename, Content-Length), ErrorMessage, ErrorException, and — on failure — any body content the server returned (commonly a problem-details JSON).
Stream is non-null only on a successful 2xx response. The caller is responsible for disposing it, same as today.
New companion interface
public interface IDownloadClient {
Task<DownloadResult> DownloadAsync(RestRequest request, CancellationToken cancellationToken = default);
}RestClient implements both IRestClient and IDownloadClient. Existing extension methods and call sites continue to work.
Rationale for a separate interface rather than adding to IRestClient:
IRestClientis the narrow request/response contract. Streaming is a separate concern.- Additive on
IRestClientwould still break every custom implementation of the interface. - This mirrors the BCL pattern of
IDisposable+IAsyncDisposable— narrow interfaces, opt-in capability.
Existing API is reimplemented in terms of the new path
// Inside RestClient
public async Task<Stream?> DownloadStreamAsync(RestRequest request, CancellationToken ct = default) {
var result = await DownloadAsync(request, ct).ConfigureAwait(false);
if (!result.IsSuccess && Options.ThrowOnAnyError) result.Response.ThrowIfError();
return result.Stream;
}DownloadDataAsync (an extension over DownloadStreamAsync) is unaffected. We can also add a DownloadDataAsync variant returning DownloadResult for parity, but that can come later.
Usage examples
Inline inspection — the case #2128 was trying to solve:
var result = await client.DownloadAsync(request);
if (!result.IsSuccess) {
throw new MyDomainException(result.Response.StatusCode, result.Response.Content);
}
using var stream = result.Stream!;
await stream.CopyToAsync(targetFile);Reading response headers for filename / content-type (raised by @DontEatRice in #2128):
var result = await client.DownloadAsync(request);
var filename = result.Response.ContentHeaders?
.FirstOrDefault(h => h.Name == \"Content-Disposition\")?.Value as string;
var contentType = result.Response.ContentType;Polite retry on 503 with Retry-After (raised by @marcoburato-ecutek in #2128):
var result = await client.DownloadAsync(request);
if (result.Response.StatusCode == HttpStatusCode.ServiceUnavailable) {
var retryAfter = result.Response.Headers?.FirstOrDefault(h => h.Name == \"Retry-After\")?.Value;
// ...
}Out of scope (for this issue)
- Deserialising the error body to a typed shape via a generic parameter (
DownloadAsync<TError>). Error shapes vary per endpoint; callers can deserialise themselves fromresult.Response.Contentif they need typed errors. Can be added later as a convenience extension. - A general
OnErrorinterceptor onInterceptor. Useful for cross-cutting concerns (logging, metrics) but doesn't substitute for per-call inspection. Tracked separately if desired. - Removing
DownloadStreamAsyncfromIRestClient. Considered and rejected for this iteration — the cost of breaking implementers exceeds the cosmetic benefit. Documented guidance will steer new code towardIDownloadClient.
Open questions
- Should
IDownloadClientalso expose aDownloadDataAsyncreturningDownloadResult<byte[]>? Probably yes for symmetry, but adds API surface. - Should
DownloadResultcarry the request URI / final URI (post-redirects) directly, or rely onResponse.RequestandResponse.ResponseUri? Probably the latter to avoid duplication. - Naming:
IDownloadClientvsIRestDownloadClientvs putting it on aRestClient.Downloadproperty. I lean towardIDownloadClientfor brevity.
Migration story
- Existing code using
DownloadStreamAsynckeeps compiling and behaving identically. - New code that needs response inspection takes a dependency on
IDownloadClient(or directly onRestClient) and usesDownloadAsync. - Docs gain a short "Inspectable downloads" section showing when to reach for
IDownloadClient.
Tracking: closes #2128 in spirit. Cross-references the earlier design discussion in that PR's thread, which substantially informed this proposal.
Source: restsharp/RestSharp