Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/breaking-changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,22 @@
Every breaking change and notable addition, newest first. Each major version links back to the feature documentation
in the [main README](../README.md).

* [V15.x.x](#v15xx)
* [V14.x.x](#v14xx)
* [V13.x.x](#v13xx)
* [V12.x.x](#v12xx)
* [V11.x.x](#v11xx)
* [Updates in 8.0.x](#updates-in-80x)
* [V6.x.x](#v6xx)

## V15.x.x

### Breaking changes in V15.x

* **Response buffering now propagates caller cancellation.** If a caller cancels while Refit buffers response content,
Refit throws `OperationCanceledException` instead of continuing with partially consumed content. Other buffering
failures remain best-effort.

## V14.x.x

### Breaking changes in V14.x
Expand Down
11 changes: 10 additions & 1 deletion src/Refit/RequestExecutionHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -637,10 +637,17 @@ settings.DeserializationExceptionFactory is not null
.ConfigureAwait(false);
}

/// <summary>Attempts to buffer content into memory, ignoring buffering failures.</summary>
/// <summary>Attempts to buffer content into memory, ignoring buffering failures but honouring cancellation.</summary>
/// <param name="content">The content to buffer.</param>
/// <param name="cancellationToken">A token to cancel buffering.</param>
/// <returns>A task that completes once buffering has been attempted.</returns>
/// <exception cref="OperationCanceledException">Thrown when the caller cancelled while buffering.</exception>
/// <remarks>
/// Buffering is best-effort: a failure just means the serializer reads the live stream instead. Cancellation is
/// not, because continuing would hand the serializer partially consumed content. The check sits after the catch
/// rather than in a filter on it, so it also covers the targets whose <c>LoadIntoBufferAsync</c> polyfill drops
/// the token and therefore never throws.
/// </remarks>
internal static async Task TryBufferContentAsync(HttpContent content, CancellationToken cancellationToken)
{
try
Expand All @@ -651,6 +658,8 @@ internal static async Task TryBufferContentAsync(HttpContent content, Cancellati
{
_ = bufferingException;
}

cancellationToken.ThrowIfCancellationRequested();
}

/// <summary>The outcome of attempting to send a request.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,45 @@ await Assert
.ThrowsExactly<OperationCanceledException>();
}

/// <summary>Verifies cancellation while buffering the response stops before the serializer sees partially consumed content.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Test]
public async Task SendAsyncStopsBeforeDeserializationWhenCancelledDuringBuffering()
{
var serializer = new RecordingContentSerializer
{
DeserializedValue = new GeneratedResult(DeserializedResultValue)
};
using var tokenSource = new CancellationTokenSource();

// Cancel once the response exists, so cancellation lands on buffering rather than on the send itself.
var handler = new CapturingHandler(
async (_, _) =>
{
await tokenSource.CancelAsync();
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("buffered")
};
});
using var client = CreateClient(handler);
using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath);

await Assert
.That(
() => GeneratedRequestRunner.SendAsync<GeneratedResult, GeneratedResult>(
client,
request,
CreateSettings(serializer),
isApiResponse: false,
shouldDisposeResponse: true,
bufferBody: false,
tokenSource.Token))
.ThrowsExactly<OperationCanceledException>();

await Assert.That(serializer.DeserializeCallCount).IsEqualTo(0);
}

/// <summary>Verifies caller-requested cancellation during send is rethrown instead of being wrapped in <see cref="ApiRequestException"/>.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Test]
Expand Down
Loading