diff --git a/.changeset/cold-buckets-gzip.md b/.changeset/cold-buckets-gzip.md new file mode 100644 index 00000000..17f94408 --- /dev/null +++ b/.changeset/cold-buckets-gzip.md @@ -0,0 +1,6 @@ +--- +'PostHog': patch +'PostHog.AspNetCore': patch +--- + +Fall back to uncompressed batch uploads when local gzip compression fails. diff --git a/src/PostHog/Library/HttpClientExtensions.cs b/src/PostHog/Library/HttpClientExtensions.cs index 4a64b68a..13b49079 100644 --- a/src/PostHog/Library/HttpClientExtensions.cs +++ b/src/PostHog/Library/HttpClientExtensions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.IO.Compression; using System.Net; using System.Net.Http.Json; @@ -12,6 +13,7 @@ namespace PostHog.Library; /// internal static class HttpClientExtensions { + internal static Func> CreateCompressedJsonContentAsync = CreateGzipJsonContentAsync; /// /// Sends a POST request to the specified Uri containing the value serialized as JSON in the request body. /// Returns the response body deserialized as . @@ -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, @@ -268,13 +270,48 @@ static Task Delay(TimeProvider timeProvider, TimeSpan delay, CancellationToken c #endif } - static async Task PostCompressedJsonAsync( + static async Task 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 TryCreateCompressedJsonContentAsync( + object content, + CancellationToken cancellationToken) + { + try + { + return await CreateCompressedJsonContentAsync(content, cancellationToken); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or NotSupportedException or ObjectDisposedException) + { + Debug.WriteLine($"Failed to gzip request body, sending uncompressed: {ex}"); + return null; + } + } + + static async Task 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)) { @@ -286,13 +323,9 @@ static async Task 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( diff --git a/tests/UnitTests/Library/HttpClientExtensionsTests.cs b/tests/UnitTests/Library/HttpClientExtensionsTests.cs index 5a9cb371..c34ffc18 100644 --- a/tests/UnitTests/Library/HttpClientExtensionsTests.cs +++ b/tests/UnitTests/Library/HttpClientExtensionsTests.cs @@ -605,6 +605,65 @@ public async Task CompressesRequestBodyWithGzip() Assert.Contains("api_key", decompressedJson, StringComparison.Ordinal); } + public static IEnumerable 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? 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(compressionException); + + try + { + await httpClient.PostJsonWithRetryAsync( + BatchUrl, + payload, + timeProvider, + options, + CancellationToken.None); + } + finally + { + HttpClientExtensions.CreateCompressedJsonContentAsync = originalCompressor; + } + + Assert.Empty(capturedContentEncoding ?? Enumerable.Empty()); + Assert.NotNull(capturedBody); + Assert.Contains("test-event", capturedBody, StringComparison.Ordinal); + Assert.Contains("api_key", capturedBody, StringComparison.Ordinal); + } + [Fact] public async Task DoesNotCompressWhenCompressionDisabled() {