Skip to content

Commit 0ce95bd

Browse files
authored
Manually handle gzipped responses (#281)
A workaround for DynamoDB quirk - it doesn't actually gzip large error responses but provides gzip header which breaks automatic decompression.
1 parent 803510e commit 0ce95bd

11 files changed

Lines changed: 309 additions & 28 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using EfficientDynamoDb.Attributes;
2+
3+
namespace EfficientDynamoDb.IntegrationTests.DataPlane.TransactWrite;
4+
5+
[DynamoDbTable(TestHelper.TestTableName)]
6+
public record TestUser
7+
{
8+
[DynamoDbProperty("pk", DynamoDbAttributeType.PartitionKey)]
9+
public required string PartitionKey { get; init; }
10+
11+
[DynamoDbProperty("sk", DynamoDbAttributeType.SortKey)]
12+
public required string SortKey { get; init; }
13+
14+
[DynamoDbProperty("name")]
15+
public string Name { get; init; } = "";
16+
17+
[DynamoDbProperty("age")]
18+
public int Age { get; init; }
19+
20+
[DynamoDbProperty("email")]
21+
public string Email { get; init; } = "";
22+
23+
[DynamoDbProperty("large_data")]
24+
public string LargeData { get; init; } = "";
25+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
using EfficientDynamoDb.Exceptions;
2+
using NUnit.Framework;
3+
using Shouldly;
4+
5+
namespace EfficientDynamoDb.IntegrationTests.DataPlane.TransactWrite;
6+
7+
[TestFixture]
8+
public class TransactWriteShould
9+
{
10+
private const string KeyPrefix = "effddb_tests-transact_write";
11+
private static readonly Random Random = new(42);
12+
13+
private DynamoDbContext _context = null!;
14+
private List<TestUser> _itemsToCleanup = null!;
15+
16+
[SetUp]
17+
public void SetUp()
18+
{
19+
_context = TestHelper.CreateContext();
20+
_itemsToCleanup = [];
21+
}
22+
23+
[TearDown]
24+
public async Task TearDown()
25+
{
26+
if (_itemsToCleanup.Count != 0)
27+
{
28+
await _context.BatchWrite()
29+
.WithItems(_itemsToCleanup.Select(user => Batch.DeleteItem<TestUser>().WithPrimaryKey(user.PartitionKey, user.SortKey)))
30+
.ExecuteAsync();
31+
}
32+
}
33+
34+
[Test]
35+
public async Task PutItemsSuccessfully()
36+
{
37+
var item1 = new TestUser
38+
{
39+
PartitionKey = $"{KeyPrefix}-put-pk-1",
40+
SortKey = $"{KeyPrefix}-put-sk-1",
41+
Name = "Transact User 1",
42+
Age = 25,
43+
Email = "transact1@example.com"
44+
};
45+
var item2 = new TestUser
46+
{
47+
PartitionKey = $"{KeyPrefix}-put-pk-2",
48+
SortKey = $"{KeyPrefix}-put-sk-2",
49+
Name = "Transact User 2",
50+
Age = 30,
51+
Email = "transact2@example.com"
52+
};
53+
54+
_itemsToCleanup.Add(item1);
55+
_itemsToCleanup.Add(item2);
56+
57+
await _context.TransactWrite()
58+
.WithItems(
59+
Transact.PutItem(item1),
60+
Transact.PutItem(item2)
61+
)
62+
.ExecuteAsync();
63+
64+
var retrieved1 = await _context.GetItem<TestUser>()
65+
.WithPrimaryKey(item1.PartitionKey, item1.SortKey)
66+
.WithConsistentRead(true)
67+
.ToItemAsync();
68+
var retrieved2 = await _context.GetItem<TestUser>()
69+
.WithPrimaryKey(item2.PartitionKey, item2.SortKey)
70+
.WithConsistentRead(true)
71+
.ToItemAsync();
72+
73+
retrieved1.ShouldBe(item1);
74+
retrieved2.ShouldBe(item2);
75+
}
76+
77+
[Test]
78+
public async Task ThrowTransactionCanceledException_WhenConditionCheckFails()
79+
{
80+
var existingItem = new TestUser
81+
{
82+
PartitionKey = $"{KeyPrefix}-cond-pk",
83+
SortKey = $"{KeyPrefix}-cond-sk",
84+
Name = "Existing User",
85+
Age = 30,
86+
Email = "existing@example.com"
87+
};
88+
89+
_itemsToCleanup.Add(existingItem);
90+
await _context.PutItemAsync(existingItem);
91+
92+
var ex = await Should.ThrowAsync<TransactionCanceledException>(() =>
93+
_context.TransactWrite()
94+
.WithItems(
95+
Transact.PutItem(existingItem).WithCondition(c => c.On(x => x.PartitionKey).NotExists())
96+
)
97+
.ExecuteAsync());
98+
99+
ex.CancellationReasons.ShouldNotBeEmpty();
100+
ex.CancellationReasons[0].Code.ShouldBe("ConditionalCheckFailed");
101+
}
102+
103+
[Test]
104+
public async Task ThrowTransactionCanceledException_WithMultipleItems_WhenConditionCheckFails()
105+
{
106+
var items = Enumerable.Range(1, 25)
107+
.Select(i => new TestUser
108+
{
109+
PartitionKey = $"{KeyPrefix}-multi-pk-{i:D3}",
110+
SortKey = $"{KeyPrefix}-multi-sk-{i:D3}",
111+
Name = $"Multi User {i}",
112+
Age = 20 + i,
113+
Email = $"multi{i}@example.com",
114+
LargeData = GenerateLargeString(1000)
115+
})
116+
.ToList();
117+
118+
_itemsToCleanup.AddRange(items);
119+
120+
// Pre-create first item so the condition check fails
121+
await _context.PutItemAsync(items[0]);
122+
123+
var ex = await Should.ThrowAsync<TransactionCanceledException>(() =>
124+
_context.TransactWrite()
125+
.WithItems(items.Select(x => Transact.PutItem(x).WithCondition(c => c.On(p => p.PartitionKey).NotExists())))
126+
.ExecuteAsync());
127+
128+
ex.CancellationReasons.ShouldNotBeEmpty();
129+
ex.CancellationReasons.Any(r => r.Code == "ConditionalCheckFailed").ShouldBeTrue();
130+
}
131+
132+
private static string GenerateLargeString(int approximateLength)
133+
{
134+
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
135+
var result = new char[approximateLength];
136+
for (var i = 0; i < approximateLength; i++)
137+
result[i] = chars[Random.Next(chars.Length)];
138+
return new string(result);
139+
}
140+
}

src/EfficientDynamoDb/Configs/Http/DefaultHttpClientFactory.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ private DefaultHttpClientFactory()
1313
{
1414
_httpClient = new HttpClient(new HttpClientHandler
1515
{
16-
AutomaticDecompression = DecompressionMethods.GZip
16+
AutomaticDecompression = DecompressionMethods.None
1717
});
1818
}
1919

src/EfficientDynamoDb/Configs/Http/IHttpClientFactory.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ namespace EfficientDynamoDb.Configs.Http
44
{
55
public interface IHttpClientFactory
66
{
7+
/// <summary>
8+
/// Creates or returns an <see cref="HttpClient"/> used for DynamoDB requests.
9+
/// </summary>
10+
/// <remarks>
11+
/// The returned <see cref="HttpClient"/> must have <c>AutomaticDecompression</c> set to <see cref="System.Net.DecompressionMethods.None"/>.
12+
/// EfficientDynamoDb handles gzip decompression manually, and enabling automatic decompression on the handler
13+
/// will interfere with CRC verification and error response parsing, leading to incorrect behavior.
14+
/// </remarks>
715
HttpClient CreateHttpClient();
816
}
917
}

src/EfficientDynamoDb/DynamoDbContext/DynamoDbContext.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ async Task<TResponse> IDynamoDbContext.ExecuteAsync<TResponse>(HttpContent httpC
4343

4444
private async ValueTask<TResult> ReadAsync<TResult>(HttpResponseMessage response, CancellationToken cancellationToken = default) where TResult : class
4545
{
46-
await using var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
46+
await using var responseStream = await response.GetDecodedStreamAsync().ConfigureAwait(false);
4747

4848
var expectedCrc = GetExpectedCrc(response);
4949
var classInfo = Config.Metadata.GetOrAddClassInfo(typeof(TResult), typeof(JsonObjectDdbConverter<TResult>));

src/EfficientDynamoDb/DynamoDbContext/DynamoDbLowLevelContext.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ private async ValueTask<HttpContent> BuildHttpContentAsync(UpdateItemRequest req
203203

204204
internal static async ValueTask<Document?> ReadDocumentAsync(HttpResponseMessage response, IParsingOptions options, CancellationToken cancellationToken = default)
205205
{
206-
await using var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
206+
await using var responseStream = await response.GetDecodedStreamAsync().ConfigureAwait(false);
207207

208208
var expectedCrc = GetExpectedCrc(response);
209209
var result = await DdbJsonReader.ReadAsync(responseStream, options, expectedCrc.HasValue, cancellationToken).ConfigureAwait(false);
@@ -216,6 +216,11 @@ private async ValueTask<HttpContent> BuildHttpContentAsync(UpdateItemRequest req
216216

217217
internal static uint? GetExpectedCrc(HttpResponseMessage response)
218218
{
219+
// Unable to verify crc for gzipped content because DDB provides expected crc for the compressed data instead of decompressed.
220+
// Validating it would require an additional pass over the compressed data to calculate crc.
221+
if (response.Content.Headers.ContentEncoding.Contains("gzip"))
222+
return null;
223+
219224
if (!response.Content.Headers.ContentLength.HasValue)
220225
return null;
221226

src/EfficientDynamoDb/DynamoDbContextConfig.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ public class DynamoDbContextConfig
2121

2222
public IAwsCredentialsProvider CredentialsProvider { get; }
2323

24+
/// <summary>
25+
/// Factory used to create the <see cref="System.Net.Http.HttpClient"/> for DynamoDB requests.
26+
/// Defaults to an internal factory with a shared <see cref="System.Net.Http.HttpClient"/>.
27+
/// </summary>
28+
/// <remarks>
29+
/// When providing a custom factory, ensure the returned <see cref="System.Net.Http.HttpClient"/> has
30+
/// <c>AutomaticDecompression</c> set to <see cref="System.Net.DecompressionMethods.None"/>.
31+
/// EfficientDynamoDb manages gzip decompression manually; enabling automatic decompression on the handler
32+
/// will interfere with CRC verification and error response parsing, leading to incorrect behavior.
33+
/// </remarks>
2434
public IHttpClientFactory HttpClientFactory { get; set; } = DefaultHttpClientFactory.Instance;
2535

2636
public IReadOnlyCollection<DdbConverter> Converters

src/EfficientDynamoDb/Internal/ErrorHandler.cs

Lines changed: 86 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.IO;
3+
using System.IO.Compression;
34
using System.Net;
45
using System.Net.Http;
56
using System.Text.Json;
@@ -21,7 +22,7 @@ internal static class ErrorHandler
2122
{
2223
PropertyNameCaseInsensitive = true,
2324
};
24-
25+
2526
public static async Task<DdbException> ProcessErrorAsync(DynamoDbContextMetadata metadata, HttpResponseMessage response, CancellationToken cancellationToken = default)
2627
{
2728
try
@@ -30,37 +31,71 @@ public static async Task<DdbException> ProcessErrorAsync(DynamoDbContextMetadata
3031
if (response.StatusCode == HttpStatusCode.ServiceUnavailable)
3132
return new ServiceUnavailableException("DynamoDB is currently unavailable. (This should be a temporary state.)");
3233

33-
var recyclableStream = new RecyclableMemoryStream(DynamoDbHttpContent.MemoryStreamManager);
34-
try
35-
{
36-
await responseStream.CopyToAsync(recyclableStream, cancellationToken).ConfigureAwait(false);
37-
38-
recyclableStream.Position = 0;
39-
var error = await JsonSerializer.DeserializeAsync<Error>(recyclableStream, SerializerOptions, cancellationToken).ConfigureAwait(false);
40-
recyclableStream.Position = 0;
41-
42-
switch (response.StatusCode)
43-
{
44-
case HttpStatusCode.BadRequest:
45-
return await ProcessBadRequestAsync(metadata, recyclableStream, error, cancellationToken).ConfigureAwait(false);
46-
case HttpStatusCode.InternalServerError:
47-
return new InternalServerErrorException(error.Message);
48-
default:
49-
return new DdbException(error.Message);
50-
}
51-
}
52-
finally
34+
var hasGzipHeader = response.Content.Headers.ContentEncoding.Contains("gzip");
35+
await using var parsedStreamOwner = await DecodeErrorStreamAsync(responseStream, hasGzipHeader, cancellationToken).ConfigureAwait(false);
36+
37+
var errorStream = parsedStreamOwner.Stream;
38+
var error = await JsonSerializer.DeserializeAsync<Error>(errorStream, SerializerOptions, cancellationToken).ConfigureAwait(false);
39+
errorStream.Position = 0;
40+
41+
return response.StatusCode switch
5342
{
54-
await recyclableStream.DisposeAsync().ConfigureAwait(false);
55-
}
43+
HttpStatusCode.BadRequest => await ProcessBadRequestAsync(metadata, errorStream, error, cancellationToken).ConfigureAwait(false),
44+
HttpStatusCode.InternalServerError => new InternalServerErrorException(error.Message),
45+
_ => new DdbException(error.Message)
46+
};
5647
}
5748
finally
5849
{
5950
response.Dispose();
6051
}
6152
}
53+
54+
private static bool IsGzipEncoded(RecyclableMemoryStream stream)
55+
{
56+
Span<byte> magic = stackalloc byte[2];
57+
_ = stream.Read(magic);
58+
stream.Position = 0;
59+
return magic[0] == 0x1F && magic[1] == 0x8B;
60+
}
61+
62+
private static async Task<DecodedStreamOwner> DecodeErrorStreamAsync(Stream responseStream, bool hasGzipHeader, CancellationToken ct = default)
63+
{
64+
var recyclableStream = new RecyclableMemoryStream(DynamoDbHttpContent.MemoryStreamManager);
65+
RecyclableMemoryStream? decompressedStream = null;
66+
try
67+
{
68+
await responseStream.CopyToAsync(recyclableStream, ct).ConfigureAwait(false);
69+
recyclableStream.Position = 0;
70+
if (!hasGzipHeader || recyclableStream.Length < 2)
71+
return new(recyclableStream, null);
72+
73+
// DynamoDB lies: error responses may carry Content-Encoding: gzip but the body is NOT gzipped.
74+
// When the header claims gzip, detect actual gzip by inspecting magic bytes (0x1F 0x8B).
75+
if (!IsGzipEncoded(recyclableStream))
76+
return new(recyclableStream, null);
77+
78+
// Rare path: error response was actually gzip-encoded — decompress eagerly into a pooled stream.
79+
decompressedStream = new RecyclableMemoryStream(DynamoDbHttpContent.MemoryStreamManager);
80+
await using (var gz = new GZipStream(recyclableStream, CompressionMode.Decompress, leaveOpen: true))
81+
{
82+
await gz.CopyToAsync(decompressedStream, ct).ConfigureAwait(false);
83+
}
84+
85+
recyclableStream.Position = 0;
86+
decompressedStream.Position = 0;
87+
return new(recyclableStream, decompressedStream);
88+
}
89+
catch (Exception)
90+
{
91+
await recyclableStream.DisposeAsync().ConfigureAwait(false);
92+
if (decompressedStream is not null)
93+
await decompressedStream.DisposeAsync().ConfigureAwait(false);
94+
throw;
95+
}
96+
}
6297

63-
private static ValueTask<DdbException> ProcessBadRequestAsync(DynamoDbContextMetadata metadata, MemoryStream recyclableStream, Error error, CancellationToken cancellationToken)
98+
private static ValueTask<DdbException> ProcessBadRequestAsync(DynamoDbContextMetadata metadata, Stream recyclableStream, Error error, CancellationToken cancellationToken)
6499
{
65100
if (error.Type is null)
66101
return new(new DdbException(string.Empty));
@@ -122,6 +157,33 @@ async ValueTask<DdbException> ParseConditionalCheckFailedException()
122157
return new ConditionalCheckFailedException(conditionalCheckFailedResponse.Value!.Item, error.Message);
123158
}
124159
}
160+
161+
private readonly struct DecodedStreamOwner : IAsyncDisposable, IDisposable
162+
{
163+
private readonly RecyclableMemoryStream _original;
164+
private readonly RecyclableMemoryStream? _decompressed;
165+
166+
public DecodedStreamOwner(RecyclableMemoryStream original, RecyclableMemoryStream? decompressed)
167+
{
168+
_original = original;
169+
_decompressed = decompressed;
170+
}
171+
172+
public Stream Stream => _decompressed ?? _original;
173+
174+
public async ValueTask DisposeAsync()
175+
{
176+
await _original.DisposeAsync().ConfigureAwait(false);
177+
if (_decompressed is not null)
178+
await _decompressed.DisposeAsync().ConfigureAwait(false);
179+
}
180+
181+
public void Dispose()
182+
{
183+
_original.Dispose();
184+
_decompressed?.Dispose();
185+
}
186+
}
125187

126188
private readonly struct Error
127189
{

0 commit comments

Comments
 (0)