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
6 changes: 6 additions & 0 deletions .changeset/cold-buckets-gzip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'PostHog': patch
'PostHog.AspNetCore': patch
---

Fall back to uncompressed batch uploads when local gzip compression fails.
53 changes: 43 additions & 10 deletions src/PostHog/Library/HttpClientExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.IO.Compression;
using System.Net;
using System.Net.Http.Json;
Expand All @@ -12,6 +13,7 @@ namespace PostHog.Library;
/// </summary>
internal static class HttpClientExtensions
{
internal static Func<object, CancellationToken, Task<ByteArrayContent>> CreateCompressedJsonContentAsync = CreateGzipJsonContentAsync;
/// <summary>
/// Sends a POST request to the specified Uri containing the value serialized as JSON in the request body.
/// Returns the response body deserialized as <typeparamref name="TBody"/>.
Expand Down Expand Up @@ -69,7 +71,7 @@ internal static class HttpClientExtensions
try
{
response = enableCompression
? await PostCompressedJsonAsync(httpClient, requestUri, content, cancellationToken)
? await PostCompressedJsonWithFallbackAsync(httpClient, requestUri, content, cancellationToken)
: await httpClient.PostAsJsonAsync(
requestUri,
content,
Expand Down Expand Up @@ -268,13 +270,48 @@ static Task Delay(TimeProvider timeProvider, TimeSpan delay, CancellationToken c
#endif
}

static async Task<HttpResponseMessage> PostCompressedJsonAsync(
static async Task<HttpResponseMessage> PostCompressedJsonWithFallbackAsync(
HttpClient httpClient,
Uri requestUri,
object content,
CancellationToken cancellationToken)
{
// Stream JSON directly into gzip to avoid intermediate allocation
var compressedContent = await TryCreateCompressedJsonContentAsync(content, cancellationToken);
if (compressedContent is null)
{
return await httpClient.PostAsJsonAsync(
requestUri,
content,
JsonSerializerHelper.Options,
cancellationToken);
}

using (compressedContent)
{
return await httpClient.PostAsync(requestUri, compressedContent, cancellationToken);
}
}

static async Task<ByteArrayContent?> TryCreateCompressedJsonContentAsync(
object content,
CancellationToken cancellationToken)
{
try
{
return await CreateCompressedJsonContentAsync(content, cancellationToken);
}
catch (Exception ex) when (ex is IOException or InvalidDataException or NotSupportedException or ObjectDisposedException)
Comment thread
marandaneto marked this conversation as resolved.
{
Debug.WriteLine($"Failed to gzip request body, sending uncompressed: {ex}");
return null;
}
}

static async Task<ByteArrayContent> CreateGzipJsonContentAsync(
object content,
CancellationToken cancellationToken)
{
// Stream JSON directly into gzip to avoid intermediate allocation and honor cancellation during serialization.
using var memoryStream = new MemoryStream(4096);
using (var gzipStream = new GZipStream(memoryStream, CompressionLevel.Fastest, leaveOpen: true))
{
Expand All @@ -286,13 +323,9 @@ static async Task<HttpResponseMessage> PostCompressedJsonAsync(
? new ByteArrayContent(buffer.Array!, buffer.Offset, buffer.Count)
: new ByteArrayContent(memoryStream.ToArray());

using (compressedContent)
{
compressedContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
compressedContent.Headers.ContentEncoding.Add("gzip");

return await httpClient.PostAsync(requestUri, compressedContent, cancellationToken);
}
compressedContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
compressedContent.Headers.ContentEncoding.Add("gzip");
return compressedContent;
}

public static async Task EnsureSuccessfulApiCall(
Expand Down
59 changes: 59 additions & 0 deletions tests/UnitTests/Library/HttpClientExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,65 @@ public async Task CompressesRequestBodyWithGzip()
Assert.Contains("api_key", decompressedJson, StringComparison.Ordinal);
}

public static IEnumerable<object[]> CompressionFailureExceptions()
{
yield return [new IOException("gzip failed")];
yield return [new InvalidDataException("gzip failed")];
yield return [new NotSupportedException("gzip failed")];
yield return [new ObjectDisposedException("gzip")];
}

[Theory]
[MemberData(nameof(CompressionFailureExceptions))]
public async Task FallsBackToUncompressedRequestWhenCompressionFails(Exception compressionException)
{
string? capturedBody = null;
IEnumerable<string>? capturedContentEncoding = null;

var handler = new LambdaHttpMessageHandler(async request =>
{
capturedContentEncoding = request.Content?.Headers.ContentEncoding;
if (request.Content != null)
{
capturedBody = await request.Content.ReadAsStringAsync();
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"status\": 1}")
};
});

using var httpClient = new HttpClient(handler);
var options = new PostHogOptions
{
ProjectToken = "test-api-key",
EnableCompression = true
};
var timeProvider = new FakeTimeProvider();
var payload = new { api_key = "test", batch = new[] { new { @event = "test-event" } } };
var originalCompressor = HttpClientExtensions.CreateCompressedJsonContentAsync;
HttpClientExtensions.CreateCompressedJsonContentAsync = (_, _) => Task.FromException<ByteArrayContent>(compressionException);

try
{
await httpClient.PostJsonWithRetryAsync<ApiResult>(
BatchUrl,
payload,
timeProvider,
options,
CancellationToken.None);
}
finally
{
HttpClientExtensions.CreateCompressedJsonContentAsync = originalCompressor;
}

Assert.Empty(capturedContentEncoding ?? Enumerable.Empty<string>());
Assert.NotNull(capturedBody);
Assert.Contains("test-event", capturedBody, StringComparison.Ordinal);
Assert.Contains("api_key", capturedBody, StringComparison.Ordinal);
}

[Fact]
public async Task DoesNotCompressWhenCompressionDisabled()
{
Expand Down
Loading