Skip to content

Commit 7bab8dc

Browse files
authored
fix: fall back when payload compression fails (#249)
* fix: fall back when payload compression fails * Address compression fallback review comments * Fix changeset package names * address compression fallback review feedback
1 parent 6d9033b commit 7bab8dc

3 files changed

Lines changed: 108 additions & 10 deletions

File tree

.changeset/cold-buckets-gzip.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'PostHog': patch
3+
'PostHog.AspNetCore': patch
4+
---
5+
6+
Fall back to uncompressed batch uploads when local gzip compression fails.

src/PostHog/Library/HttpClientExtensions.cs

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Diagnostics;
12
using System.IO.Compression;
23
using System.Net;
34
using System.Net.Http.Json;
@@ -13,6 +14,7 @@ namespace PostHog.Library;
1314
/// </summary>
1415
internal static class HttpClientExtensions
1516
{
17+
internal static Func<object, CancellationToken, Task<ByteArrayContent>> CreateCompressedJsonContentAsync = CreateGzipJsonContentAsync;
1618
/// <summary>
1719
/// Sends a POST request to the specified Uri containing the value serialized as JSON in the request body.
1820
/// Returns the response body deserialized as <typeparamref name="TBody"/>.
@@ -148,7 +150,7 @@ or SocketError.NetworkReset
148150
try
149151
{
150152
response = enableCompression
151-
? await PostCompressedJsonAsync(httpClient, requestUri, content, cancellationToken)
153+
? await PostCompressedJsonWithFallbackAsync(httpClient, requestUri, content, cancellationToken)
152154
: await httpClient.PostAsJsonAsync(
153155
requestUri,
154156
content,
@@ -347,13 +349,48 @@ static Task Delay(TimeProvider timeProvider, TimeSpan delay, CancellationToken c
347349
#endif
348350
}
349351

350-
static async Task<HttpResponseMessage> PostCompressedJsonAsync(
352+
static async Task<HttpResponseMessage> PostCompressedJsonWithFallbackAsync(
351353
HttpClient httpClient,
352354
Uri requestUri,
353355
object content,
354356
CancellationToken cancellationToken)
355357
{
356-
// Stream JSON directly into gzip to avoid intermediate allocation
358+
var compressedContent = await TryCreateCompressedJsonContentAsync(content, cancellationToken);
359+
if (compressedContent is null)
360+
{
361+
return await httpClient.PostAsJsonAsync(
362+
requestUri,
363+
content,
364+
JsonSerializerHelper.Options,
365+
cancellationToken);
366+
}
367+
368+
using (compressedContent)
369+
{
370+
return await httpClient.PostAsync(requestUri, compressedContent, cancellationToken);
371+
}
372+
}
373+
374+
static async Task<ByteArrayContent?> TryCreateCompressedJsonContentAsync(
375+
object content,
376+
CancellationToken cancellationToken)
377+
{
378+
try
379+
{
380+
return await CreateCompressedJsonContentAsync(content, cancellationToken);
381+
}
382+
catch (Exception ex) when (ex is IOException or InvalidDataException or NotSupportedException or ObjectDisposedException)
383+
{
384+
Debug.WriteLine($"Failed to gzip request body, sending uncompressed: {ex}");
385+
return null;
386+
}
387+
}
388+
389+
static async Task<ByteArrayContent> CreateGzipJsonContentAsync(
390+
object content,
391+
CancellationToken cancellationToken)
392+
{
393+
// Stream JSON directly into gzip to avoid intermediate allocation and honor cancellation during serialization.
357394
using var memoryStream = new MemoryStream(4096);
358395
using (var gzipStream = new GZipStream(memoryStream, CompressionLevel.Fastest, leaveOpen: true))
359396
{
@@ -365,13 +402,9 @@ static async Task<HttpResponseMessage> PostCompressedJsonAsync(
365402
? new ByteArrayContent(buffer.Array!, buffer.Offset, buffer.Count)
366403
: new ByteArrayContent(memoryStream.ToArray());
367404

368-
using (compressedContent)
369-
{
370-
compressedContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
371-
compressedContent.Headers.ContentEncoding.Add("gzip");
372-
373-
return await httpClient.PostAsync(requestUri, compressedContent, cancellationToken);
374-
}
405+
compressedContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
406+
compressedContent.Headers.ContentEncoding.Add("gzip");
407+
return compressedContent;
375408
}
376409

377410
public static async Task EnsureSuccessfulApiCall(

tests/UnitTests/Library/HttpClientExtensionsTests.cs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,65 @@ public async Task CompressesRequestBodyWithGzip()
827827
Assert.Contains("api_key", decompressedJson, StringComparison.Ordinal);
828828
}
829829

830+
public static IEnumerable<object[]> CompressionFailureExceptions()
831+
{
832+
yield return [new IOException("gzip failed")];
833+
yield return [new InvalidDataException("gzip failed")];
834+
yield return [new NotSupportedException("gzip failed")];
835+
yield return [new ObjectDisposedException("gzip")];
836+
}
837+
838+
[Theory]
839+
[MemberData(nameof(CompressionFailureExceptions))]
840+
public async Task FallsBackToUncompressedRequestWhenCompressionFails(Exception compressionException)
841+
{
842+
string? capturedBody = null;
843+
IEnumerable<string>? capturedContentEncoding = null;
844+
845+
var handler = new LambdaHttpMessageHandler(async request =>
846+
{
847+
capturedContentEncoding = request.Content?.Headers.ContentEncoding;
848+
if (request.Content != null)
849+
{
850+
capturedBody = await request.Content.ReadAsStringAsync();
851+
}
852+
return new HttpResponseMessage(HttpStatusCode.OK)
853+
{
854+
Content = new StringContent("{\"status\": 1}")
855+
};
856+
});
857+
858+
using var httpClient = new HttpClient(handler);
859+
var options = new PostHogOptions
860+
{
861+
ProjectToken = "test-api-key",
862+
EnableCompression = true
863+
};
864+
var timeProvider = new FakeTimeProvider();
865+
var payload = new { api_key = "test", batch = new[] { new { @event = "test-event" } } };
866+
var originalCompressor = HttpClientExtensions.CreateCompressedJsonContentAsync;
867+
HttpClientExtensions.CreateCompressedJsonContentAsync = (_, _) => Task.FromException<ByteArrayContent>(compressionException);
868+
869+
try
870+
{
871+
await httpClient.PostJsonWithRetryAsync<ApiResult>(
872+
BatchUrl,
873+
payload,
874+
timeProvider,
875+
options,
876+
CancellationToken.None);
877+
}
878+
finally
879+
{
880+
HttpClientExtensions.CreateCompressedJsonContentAsync = originalCompressor;
881+
}
882+
883+
Assert.Empty(capturedContentEncoding ?? Enumerable.Empty<string>());
884+
Assert.NotNull(capturedBody);
885+
Assert.Contains("test-event", capturedBody, StringComparison.Ordinal);
886+
Assert.Contains("api_key", capturedBody, StringComparison.Ordinal);
887+
}
888+
830889
[Fact]
831890
public async Task DoesNotCompressWhenCompressionDisabled()
832891
{

0 commit comments

Comments
 (0)