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
ExecuteAsync gives for normal requests: full RestResponse access on both success and failure.
- Keep the existing
DownloadStreamAsync API working unchanged. No deprecation, no break for current consumers or IRestClient implementers.
- Avoid expanding
IRestClient further — the interface is intentionally narrow, and DownloadStreamAsync arguably 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:
IRestClient is the narrow request/response contract. Streaming is a separate concern.
- Additive on
IRestClient would 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 from result.Response.Content if they need typed errors. Can be added later as a convenience extension.
- A general
OnError interceptor on Interceptor. Useful for cross-cutting concerns (logging, metrics) but doesn't substitute for per-call inspection. Tracked separately if desired.
- Removing
DownloadStreamAsync from IRestClient. Considered and rejected for this iteration — the cost of breaking implementers exceeds the cosmetic benefit. Documented guidance will steer new code toward IDownloadClient.
Open questions
- Should
IDownloadClient also expose a DownloadDataAsync returning DownloadResult<byte[]>? Probably yes for symmetry, but adds API surface.
- Should
DownloadResult carry the request URI / final URI (post-redirects) directly, or rely on Response.Request and Response.ResponseUri? Probably the latter to avoid duplication.
- Naming:
IDownloadClient vs IRestDownloadClient vs putting it on a RestClient.Download property. I lean toward IDownloadClient for brevity.
Migration story
- Existing code using
DownloadStreamAsync keeps compiling and behaving identically.
- New code that needs response inspection takes a dependency on
IDownloadClient (or directly on RestClient) and uses DownloadAsync.
- 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.
Background
IRestClient.DownloadStreamAsyncreturnsTask<Stream?>. On failure it either throws (whenThrowOnAnyError = true) or returnsnull— in both cases the caller loses access to theRestResponse: status code, headers, response body, error details. This makes downloads materially less usable thanExecuteAsync, where the fullRestResponseis 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
ExecuteAsyncgives for normal requests: fullRestResponseaccess on both success and failure.DownloadStreamAsyncAPI working unchanged. No deprecation, no break for current consumers orIRestClientimplementers.IRestClientfurther — the interface is intentionally narrow, andDownloadStreamAsyncarguably should not have been on it in the first place.HttpCompletionOption.ResponseHeadersRead, no buffered byte materialisation on success).Proposal
New result type
Responseis always present and inspectable:StatusCode,Headers,ContentHeaders(includingContent-Type,Content-Disposition/filename,Content-Length),ErrorMessage,ErrorException, and — on failure — any body content the server returned (commonly a problem-details JSON).Streamis non-null only on a successful 2xx response. The caller is responsible for disposing it, same as today.New companion interface
RestClientimplements bothIRestClientandIDownloadClient. 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.IRestClientwould still break every custom implementation of the interface.IDisposable+IAsyncDisposable— narrow interfaces, opt-in capability.Existing API is reimplemented in terms of the new path
DownloadDataAsync(an extension overDownloadStreamAsync) is unaffected. We can also add aDownloadDataAsyncvariant returningDownloadResultfor parity, but that can come later.Usage examples
Inline inspection — the case #2128 was trying to solve:
Reading response headers for filename / content-type (raised by @DontEatRice in #2128):
Polite retry on 503 with
Retry-After(raised by @marcoburato-ecutek in #2128):Out of scope (for this issue)
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.OnErrorinterceptor onInterceptor. Useful for cross-cutting concerns (logging, metrics) but doesn't substitute for per-call inspection. Tracked separately if desired.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
IDownloadClientalso expose aDownloadDataAsyncreturningDownloadResult<byte[]>? Probably yes for symmetry, but adds API surface.DownloadResultcarry the request URI / final URI (post-redirects) directly, or rely onResponse.RequestandResponse.ResponseUri? Probably the latter to avoid duplication.IDownloadClientvsIRestDownloadClientvs putting it on aRestClient.Downloadproperty. I lean towardIDownloadClientfor brevity.Migration story
DownloadStreamAsynckeeps compiling and behaving identically.IDownloadClient(or directly onRestClient) and usesDownloadAsync.IDownloadClient.Tracking: closes #2128 in spirit. Cross-references the earlier design discussion in that PR's thread, which substantially informed this proposal.