
Beware of These Hidden Pitfalls When Implementing Custom HttpContent in C#
Implementing custom HttpContent
in C# can unlock powerful flexibility, but it comes with subtle details that can lead to tricky bugs. These nuances aren’t fully covered in the official documentation, yet they’re crucial for ensuring reliable HTTP requests. In this article, we’ll explore hidden aspects of custom HttpContent
implementations based on our experiences with RavenDB.
The importance of flushing in SerializeToStreamAsync
to force sending the headers
The first issue we encountered involved occasional request hangs during our test suite execution with the RavenDB server. After exploring related issues on GitHub, we found relevant discussions:
Since SerializeToStreamAsync
is asynchronous, content may not be immediately available when the request is sent. To force the headers to go out before the body, we discovered we needed to explicitly call FlushAsync
on the request body stream. As pointed out in Issue #30295 (comment):
“If you aren’t going to immediately send request body data, but you do want the request to be sent to the server immediately, then you need to call FlushAsync
on the request body to guarantee this.”
Here’s a code example:
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context)
{
// Immediately flush request stream to send headers
await stream.FlushAsync().ConfigureAwait(false);
// Actual write of data to request stream
...
}
Without explicitly flushing, the headers are not guaranteed to be sent immediately. Libraries like gRPC for .NET also use FlushAsync
in their PushStreamContent, which inherits from HttpContent
, to ensure headers are sent promptly. This behavior is confirmed by tests in the System.Net.Http
package (Issue #96223 (comment)).
Adding this explicit FlushAsync call can resolve delay or deadlock issues, as seen in our Pull Request #18552.
Understanding HttpClient.SendAsync
completion timing
The next issue was even more subtle and surprising. According to Issue #107082 (comment):
“Yes, there are cases where the task returned by HttpClient.SendAsync
may be completed before the call to the request content’s SerializeToStreamAsync
completes.”
We initially assumed that once SendAsync
completed, it was safe to release resources allocated for the request body. However, we found that SerializeToStreamAsync
could still be running, leading to premature memory release and occasional AccessViolationException
errors.
To fix this, we added a check in a finally block to wait for SerializeToStreamAsync
to finish if it’s still running:
try
{
return await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false);
}
finally
{
if (request.Content is BlittableJsonContent bjc)
{
var requestBodyTask = bjc.EnsureCompletedAsync();
if (requestBodyTask.IsCompleted == false)
await requestBodyTask.ConfigureAwait(false);
}
}
The EnsureCompletedAsync
method returns a task that completes only when SerializeToStreamAsync
is done. This way, we avoid freeing memory prematurely, as implemented in our Pull Request #19168.
Additional Things to Watch For
In addition to the two main issues, several other considerations were highlighted in Issue #107082 (comment):
SerializeToStreamAsync
not being called. Be prepared for situations where it might never be called, especially if the request is canceled early.- Multiple calls to
SerializeToStreamAsync
on retries. If a request is retried,SerializeToStreamAsync
could be called multiple times. Make sure your implementation can handle this, or that it properly throws if it cannot support retries.
In our BlittableJsonContent
implementation, we handle these scenarios. If SerializeToStreamAsync
isn’t called, EnsureCompletedAsync
simply returns a completed task to avoid indefinite waiting. For retries, our implementation throws an exception if SerializeToStreamAsync
has already consumed some of the data.
Conclusion
Implementing custom HttpContent
can offer significant flexibility, but careful attention to details like FlushAsync
and HttpClient.SendAsync
completion timing is essential. Awareness of these nuances helps you avoid subtle bugs and create reliable HTTP requests. Learning from our experiences in RavenDB can ensure that your code handles these critical details effectively.
Woah, already finished? 🤯
If you found the article interesting, don’t miss a chance to try our database solution – totally for free!