ChilliCream / ChilliCream/graphql-platform

Strawberry Shake raises transport error when cancelled after headers received

Open
#6,808 1 comment 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

🌶️ strawberry shake
Dominant language
C#
Stars
5.8k
Forks
810
Avg merge
15h 39m
Merged PRs (30d)
98

Description

Is there an existing issue for this?
  • I have searched the existing issues
Product

Hot Chocolate

Describe the bug

Similar to https://github.com/ChilliCream/graphql-platform/issues/2880 (reintroduced?), Strawberry Shake's HttpConnection does not properly acknowledge cancellation of an HTTP request in all scenarios. In the operation executor, this manifests as an erroneous GraphQLClientException instead of the expected OperationCanceledException

Steps to reproduce
  1. Run the following program:
using HotChocolate.Transport;
using StrawberryShake;
using StrawberryShake.Transport.Http;
using System.Net;
using System.Net.Http.Headers;

Console.WriteLine("Testing cancellation before headers...");
await TestCancel(afterHeaders: false);

Console.WriteLine("Testing cancellation after headers...");
await TestCancel(afterHeaders: true);

static async Task TestCancel(bool afterHeaders)
{
    using var cancellationSource = new CancellationTokenSource();

    var connection = new HttpConnection(
        () => FakeHttp.CreateCancellingClient(cancellationSource, afterHeaders ? ConfigureResponse : null));

    static void ConfigureResponse(HttpResponseMessage message)
    {
        message.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(ContentType.GraphQL);
    }

    var response = connection.ExecuteAsync(new(PingDocument.OperationName, PingDocument.Instance));
    await using var cursor = response.GetAsyncEnumerator(cancellationSource.Token);

    try
    {
        if (await cursor.MoveNextAsync())
        {
            Console.WriteLine($"Cancellation was unsuccessful: {cursor.Current.Body?.RootElement}");
        }
        else
        {
            Console.WriteLine("Cancellation was unsuccessful (no response)");
        }
    }
    catch (OperationCanceledException exception) when (exception.CancellationToken == cancellationSource.Token)
    {
        Console.WriteLine("Cancellation acknowledged successfully!");
    }
}

class PingDocument : IDocument
{
    private PingDocument()
    {
    }

    public static PingDocument Instance { get; } = new();

    public static string OperationName { get; } = "PingQuery";

    public OperationKind Kind => OperationKind.Query;

    public ReadOnlySpan<byte> Body => "query PingQuery { __typename }"u8;

    public DocumentHash Hash { get; } = new("PING", string.Empty);
}

static class FakeHttp
{
    public static HttpClient CreateCancellingClient(
        CancellationTokenSource cancellationSource,
        Action<HttpResponseMessage>? configureResponse = null)
    {
        return new HttpClient(new CancellingHandler(cancellationSource, configureResponse))
        {
            BaseAddress = new("http://localhost"),
        };
    }

    sealed class CancellingHandler(
        CancellationTokenSource cancellationSource,
        Action<HttpResponseMessage>? configureResponse)
        : HttpMessageHandler
    {
        protected override Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request,
            CancellationToken cancellationToken)
        {
            if (configureResponse != null)
            {
                var message = new HttpResponseMessage
                {
                    Content = new CancellingContent(this),
                };

                configureResponse(message);
                return Task.FromResult(message);
            }

            TriggerCancel(cancellationToken);
            return Task.FromCanceled<HttpResponseMessage>(cancellationToken);
        }

        private void TriggerCancel(CancellationToken cancellationToken)
        {
            cancellationSource.Cancel();

            if (!cancellationToken.IsCancellationRequested)
            {
                throw new InvalidOperationException("Token is not linked to cancellation source.");
            }
        }

        sealed class CancellingContent(CancellingHandler handler) : HttpContent
        {
            protected override Task<Stream> CreateContentReadStreamAsync(CancellationToken cancellationToken)
            {
                handler.TriggerCancel(cancellationToken);
                return Task.FromCanceled<Stream>(cancellationToken);
            }

            protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context)
            {
                throw new NotImplementedException();
            }

            protected override bool TryComputeLength(out long length)
            {
                throw new NotImplementedException();
            }
        }
    }
}
Relevant log output
Testing cancellation before headers...
Cancellation acknowledged successfully!
Testing cancellation after headers...
Cancellation was unsuccessful: {"errors":[{"message":"A task was canceled."}]}
Additional Context?

.NET 8 on Windows 10

Project File:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="StrawberryShake.Transport.Http" Version="13.8.1" />
  </ItemGroup>

</Project>
Version

13.8.1

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start at Strawberry Shake's HttpConnection and the operation executor, using the supplied cancellation program to reproduce the difference between cancellation before and after headers. Done means cancellation after headers is acknowledged as OperationCanceledException rather than returned as a GraphQLClientException containing a canceled-task error.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
networking
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.