Skip to content

Commit 408ddea

Browse files
committed
Add support for response hooks in idempotency configuration
- Introduce ResponseHook delegate to IdempotencyOptions and builder - Invoke response hook on idempotent responses in IdempotencyAspectHandler - Update documentation with usage examples and best practices for response hooks - Add integration tests for response hook execution and exception handling - Improve XML doc comments for builder and persistence store
1 parent 0d3043e commit 408ddea

File tree

6 files changed

+266
-10
lines changed

6 files changed

+266
-10
lines changed

docs/utilities/idempotency.md

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,50 @@ Data would then be stored in DynamoDB like this:
846846
| idempotency#MyLambdaFunction | f091d2527ad1c78f05d54cc3f363be80 | 1636549585 | IN_PROGRESS | |
847847

848848

849+
### Manipulating the Idempotent Response
850+
851+
You can set up a response hook in the Idempotency configuration to manipulate the returned data when an operation is idempotent. The hook function will be called with the current deserialized response object and the Idempotency `DataRecord`.
852+
853+
#### Using Response Hooks
854+
855+
The example below shows how to append HTTP headers to an `APIGatewayProxyResponse`:
856+
857+
```csharp
858+
Idempotency.Config()
859+
.WithConfig(IdempotencyOptions.Builder()
860+
.WithEventKeyJmesPath("powertools_json(body).address")
861+
.WithResponseHook((responseData, dataRecord) => {
862+
if (responseData is APIGatewayProxyResponse proxyResponse)
863+
{
864+
proxyResponse.Headers ??= new Dictionary<string, string>();
865+
proxyResponse.Headers["x-idempotency-response"] = "true";
866+
proxyResponse.Headers["x-idempotency-expiration"] = dataRecord.ExpiryTimestamp.ToString();
867+
return proxyResponse;
868+
}
869+
return responseData;
870+
})
871+
.Build())
872+
.WithPersistenceStore(DynamoDBPersistenceStore.Builder()
873+
.WithTableName(Environment.GetEnvironmentVariable("IDEMPOTENCY_TABLE"))
874+
.Build())
875+
.Configure();
876+
```
877+
878+
???+ info "Info: Using custom de-serialization?"
879+
880+
The response hook is called after de-serialization so the payload you process will be the de-serialized Java object.
881+
882+
#### Being a good citizen
883+
884+
When using response hooks to manipulate returned data from idempotent operations, it's important to follow best practices to avoid introducing complexity or issues. Keep these guidelines in mind:
885+
886+
1. **Response hook works exclusively when operations are idempotent.** The hook will not be called when an operation is not idempotent, or when the idempotent logic fails.
887+
888+
2. **Catch and Handle Exceptions.** Your response hook code should catch and handle any exceptions that may arise from your logic. Unhandled exceptions will cause the Lambda function to fail unexpectedly.
889+
890+
3. **Keep Hook Logic Simple** Response hooks should consist of minimal and straightforward logic for manipulating response data. Avoid complex conditional branching and aim for hooks that are easy to reason about.
891+
892+
849893
## AOT Support
850894

851895
Native AOT trims your application code as part of the compilation to ensure that the binary is as small as possible. .NET 8 for Lambda provides improved trimming support compared to previous versions of .NET.
@@ -921,4 +965,4 @@ When testing your code, you may wish to disable the idempotency logic altogether
921965
## Extra resources
922966

923967
If you're interested in a deep dive on how Amazon uses idempotency when building our APIs, check out
924-
[this article](https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/).
968+
[this article](https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/).

libraries/src/AWS.Lambda.Powertools.Idempotency/IdempotencyOptions.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
* permissions and limitations under the License.
1414
*/
1515

16+
using System;
17+
1618
namespace AWS.Lambda.Powertools.Idempotency;
1719

1820
/// <summary>
@@ -57,6 +59,10 @@ public class IdempotencyOptions
5759
/// as supported by <see cref="System.Security.Cryptography.HashAlgorithm"/> (eg. SHA1, SHA-256, ...)
5860
/// </summary>
5961
public string HashFunction { get; }
62+
/// <summary>
63+
/// Delegate for manipulating idempotent responses.
64+
/// </summary>
65+
public Func<object, Persistence.DataRecord, object> ResponseHook { get; }
6066

6167
/// <summary>
6268
/// Constructor of <see cref="IdempotencyOptions"/>.
@@ -68,14 +74,16 @@ public class IdempotencyOptions
6874
/// <param name="localCacheMaxItems"></param>
6975
/// <param name="expirationInSeconds"></param>
7076
/// <param name="hashFunction"></param>
77+
/// <param name="responseHook"></param>
7178
internal IdempotencyOptions(
7279
string eventKeyJmesPath,
7380
string payloadValidationJmesPath,
7481
bool throwOnNoIdempotencyKey,
7582
bool useLocalCache,
7683
int localCacheMaxItems,
7784
long expirationInSeconds,
78-
string hashFunction)
85+
string hashFunction,
86+
Func<object, Persistence.DataRecord, object> responseHook = null)
7987
{
8088
EventKeyJmesPath = eventKeyJmesPath;
8189
PayloadValidationJmesPath = payloadValidationJmesPath;
@@ -84,5 +92,6 @@ internal IdempotencyOptions(
8492
LocalCacheMaxItems = localCacheMaxItems;
8593
ExpirationInSeconds = expirationInSeconds;
8694
HashFunction = hashFunction;
95+
ResponseHook = responseHook;
8796
}
8897
}

libraries/src/AWS.Lambda.Powertools.Idempotency/IdempotencyOptionsBuilder.cs

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,21 +43,27 @@ public class IdempotencyOptionsBuilder
4343
private string _hashFunction = "MD5";
4444

4545
/// <summary>
46-
/// Initialize and return an instance of IdempotencyConfig.
46+
/// Response hook function
47+
/// </summary>
48+
private Func<object, AWS.Lambda.Powertools.Idempotency.Persistence.DataRecord, object> _responseHook;
49+
50+
/// <summary>
51+
/// Initialize and return an instance of IdempotencyOptions.
4752
/// Example:
48-
/// IdempotencyConfig.Builder().WithUseLocalCache().Build();
49-
/// This instance must then be passed to the Idempotency.Config:
50-
/// Idempotency.Config().WithConfig(config).Configure();
53+
/// new IdempotencyOptionsBuilder().WithUseLocalCache().Build();
54+
/// This instance can then be passed to Idempotency.Configure:
55+
/// Idempotency.Configure(builder => builder.WithOptions(options));
5156
/// </summary>
52-
/// <returns>an instance of IdempotencyConfig</returns>
57+
/// <returns>an instance of IdempotencyOptions</returns>
5358
public IdempotencyOptions Build() =>
5459
new(_eventKeyJmesPath,
5560
_payloadValidationJmesPath,
5661
_throwOnNoIdempotencyKey,
5762
_useLocalCache,
5863
_localCacheMaxItems,
5964
_expirationInSeconds,
60-
_hashFunction);
65+
_hashFunction,
66+
_responseHook);
6167

6268
/// <summary>
6369
/// A JMESPath expression to extract the idempotency key from the event record.
@@ -133,4 +139,15 @@ public IdempotencyOptionsBuilder WithHashFunction(string hashFunction)
133139
#endif
134140
return this;
135141
}
142+
143+
/// <summary>
144+
/// Set a response hook function, to be called with the response and the data record.
145+
/// </summary>
146+
/// <param name="hook">The response hook function</param>
147+
/// <returns>the instance of the builder (to chain operations)</returns>
148+
public IdempotencyOptionsBuilder WithResponseHook(Func<object, AWS.Lambda.Powertools.Idempotency.Persistence.DataRecord, object> hook)
149+
{
150+
_responseHook = hook;
151+
return this;
152+
}
136153
}

libraries/src/AWS.Lambda.Powertools.Idempotency/Internal/IdempotencyAspectHandler.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,24 @@ private Task<T> HandleForStatus(DataRecord record)
208208
{
209209
throw new IdempotencyPersistenceLayerException("Unable to cast function response as " + typeof(T).Name);
210210
}
211+
// Response hook logic
212+
var responseHook = Idempotency.Instance.IdempotencyOptions?.ResponseHook;
213+
if (responseHook != null)
214+
{
215+
try
216+
{
217+
var hooked = responseHook(result, record);
218+
if (hooked is T typedHooked)
219+
{
220+
return Task.FromResult(typedHooked);
221+
}
222+
// If hook returns wrong type, fallback to original result
223+
}
224+
catch (Exception)
225+
{
226+
// If hook throws, fallback to original result
227+
}
228+
}
211229
return Task.FromResult(result);
212230
}
213231
catch (Exception e)

libraries/src/AWS.Lambda.Powertools.Idempotency/Persistence/DynamoDBPersistenceStore.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -370,9 +370,9 @@ public class DynamoDBPersistenceStoreBuilder
370370
private AmazonDynamoDBClient _dynamoDbClient;
371371

372372
/// <summary>
373-
/// Initialize and return a new instance of {@link DynamoDBPersistenceStore}.
373+
/// Initialize and return a new instance of <see cref="DynamoDBPersistenceStore"/>.
374374
/// Example:
375-
/// DynamoDBPersistenceStore.builder().withTableName("idempotency_store").build();
375+
/// new DynamoDBPersistenceStoreBuilder().WithTableName("idempotency_store").Build();
376376
/// </summary>
377377
/// <returns></returns>
378378
/// <exception cref="ArgumentNullException"></exception>
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Threading.Tasks;
5+
using Amazon.DynamoDBv2;
6+
using Amazon.Lambda.APIGatewayEvents;
7+
using AWS.Lambda.Powertools.Idempotency.Internal.Serializers;
8+
using AWS.Lambda.Powertools.Idempotency.Persistence;
9+
using AWS.Lambda.Powertools.Idempotency.Tests.Persistence;
10+
using FluentAssertions;
11+
using Xunit;
12+
13+
namespace AWS.Lambda.Powertools.Idempotency.Tests;
14+
15+
public class ResponseHookTest : IClassFixture<DynamoDbFixture>
16+
{
17+
private readonly AmazonDynamoDBClient _client;
18+
private readonly string _tableName;
19+
20+
public ResponseHookTest(DynamoDbFixture fixture)
21+
{
22+
_client = fixture.Client;
23+
_tableName = fixture.TableName;
24+
}
25+
26+
[Fact]
27+
[Trait("Category", "Integration")]
28+
public async Task ResponseHook_ShouldNotExecuteOnFirstCall()
29+
{
30+
// Arrange
31+
var hookExecuted = false;
32+
33+
Idempotency.Configure(builder => builder
34+
.WithOptions(options => options
35+
.WithEventKeyJmesPath("powertools_json(body).address")
36+
.WithResponseHook((responseData, dataRecord) => {
37+
hookExecuted = true;
38+
if (responseData is APIGatewayProxyResponse proxyResponse)
39+
{
40+
var headers = new Dictionary<string, string>(proxyResponse.Headers ?? new Dictionary<string, string>());
41+
headers["x-idempotency-response"] = "true";
42+
headers["x-idempotency-expiration"] = dataRecord.ExpiryTimestamp.ToString();
43+
proxyResponse.Headers = headers;
44+
return proxyResponse;
45+
}
46+
return responseData;
47+
}))
48+
.WithPersistenceStore(new DynamoDBPersistenceStoreBuilder()
49+
.WithTableName(_tableName)
50+
.WithDynamoDBClient(_client)
51+
.Build()));
52+
53+
var function = new ResponseHookTestFunction();
54+
var request = IdempotencySerializer.Deserialize<APIGatewayProxyRequest>(
55+
await File.ReadAllTextAsync("./resources/apigw_event2.json"));
56+
57+
// Act - First call
58+
var response = await function.Handle(request);
59+
60+
// Assert - Hook should not execute on first call
61+
hookExecuted.Should().BeFalse();
62+
response.Headers.Should().NotContainKey("x-idempotency-response");
63+
function.HandlerExecuted.Should().BeTrue();
64+
}
65+
66+
[Fact]
67+
[Trait("Category", "Integration")]
68+
public async Task ResponseHook_ShouldExecuteOnIdempotentCall()
69+
{
70+
// Arrange
71+
var hookExecuted = false;
72+
73+
Idempotency.Configure(builder => builder
74+
.WithOptions(options => options
75+
.WithEventKeyJmesPath("powertools_json(body).address")
76+
.WithResponseHook((responseData, dataRecord) => {
77+
hookExecuted = true;
78+
if (responseData is APIGatewayProxyResponse proxyResponse)
79+
{
80+
var headers = new Dictionary<string, string>(proxyResponse.Headers ?? new Dictionary<string, string>());
81+
headers["x-idempotency-response"] = "true";
82+
headers["x-idempotency-expiration"] = dataRecord.ExpiryTimestamp.ToString();
83+
proxyResponse.Headers = headers;
84+
return proxyResponse;
85+
}
86+
return responseData;
87+
}))
88+
.WithPersistenceStore(new DynamoDBPersistenceStoreBuilder()
89+
.WithTableName(_tableName)
90+
.WithDynamoDBClient(_client)
91+
.Build()));
92+
93+
var function = new ResponseHookTestFunction();
94+
var request = IdempotencySerializer.Deserialize<APIGatewayProxyRequest>(
95+
await File.ReadAllTextAsync("./resources/apigw_event2.json"));
96+
97+
// Act - First call to populate cache
98+
await function.Handle(request);
99+
function.HandlerExecuted = false;
100+
hookExecuted = false;
101+
102+
// Act - Second call (idempotent)
103+
var response = await function.Handle(request);
104+
105+
// Assert - Hook should execute on idempotent call
106+
hookExecuted.Should().BeTrue();
107+
response.Headers.Should().ContainKey("x-idempotency-response");
108+
response.Headers["x-idempotency-response"].Should().Be("true");
109+
response.Headers.Should().ContainKey("x-idempotency-expiration");
110+
function.HandlerExecuted.Should().BeFalse();
111+
}
112+
113+
[Fact]
114+
[Trait("Category", "Integration")]
115+
public async Task ResponseHook_ShouldHandleExceptionsGracefully()
116+
{
117+
// Arrange
118+
Idempotency.Configure(builder => builder
119+
.WithOptions(options => options
120+
.WithEventKeyJmesPath("powertools_json(body).address")
121+
.WithResponseHook((responseData, dataRecord) => {
122+
throw new InvalidOperationException("Hook failed");
123+
}))
124+
.WithPersistenceStore(new DynamoDBPersistenceStoreBuilder()
125+
.WithTableName(_tableName)
126+
.WithDynamoDBClient(_client)
127+
.Build()));
128+
129+
var function = new ResponseHookTestFunction();
130+
var request = IdempotencySerializer.Deserialize<APIGatewayProxyRequest>(
131+
await File.ReadAllTextAsync("./resources/apigw_event2.json"));
132+
133+
// Act - First call to populate cache
134+
var firstResponse = await function.Handle(request);
135+
function.HandlerExecuted = false;
136+
137+
// Act - Second call (idempotent) - should not throw despite hook exception
138+
var response = await function.Handle(request);
139+
140+
// Assert - Should return original response despite hook exception
141+
response.Should().NotBeNull();
142+
response.Body.Should().Be(firstResponse.Body);
143+
function.HandlerExecuted.Should().BeFalse();
144+
}
145+
}
146+
147+
public class ResponseHookTestFunction
148+
{
149+
public bool HandlerExecuted { get; set; }
150+
151+
[Idempotent]
152+
public async Task<APIGatewayProxyResponse> Handle(APIGatewayProxyRequest request)
153+
{
154+
HandlerExecuted = true;
155+
156+
await Task.Delay(100); // Simulate some work
157+
158+
return new APIGatewayProxyResponse
159+
{
160+
StatusCode = 200,
161+
Body = "Hello World",
162+
Headers = new Dictionary<string, string>
163+
{
164+
["Content-Type"] = "application/json"
165+
}
166+
};
167+
}
168+
}

0 commit comments

Comments
 (0)