Skip to content

Commit 7d26f07

Browse files
authored
Merge pull request #968 from aws-powertools/feature/idempotency-hooks
feat: Add support for response hooks in idempotency utility
2 parents 876af18 + 87f6387 commit 7d26f07

File tree

6 files changed

+310
-37
lines changed

6 files changed

+310
-37
lines changed

docs/utilities/idempotency.md

Lines changed: 44 additions & 0 deletions
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 C# 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.

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

Lines changed: 16 additions & 22 deletions
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,32 +59,24 @@ 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"/>.
6369
/// </summary>
64-
/// <param name="eventKeyJmesPath"></param>
65-
/// <param name="payloadValidationJmesPath"></param>
66-
/// <param name="throwOnNoIdempotencyKey"></param>
67-
/// <param name="useLocalCache"></param>
68-
/// <param name="localCacheMaxItems"></param>
69-
/// <param name="expirationInSeconds"></param>
70-
/// <param name="hashFunction"></param>
71-
internal IdempotencyOptions(
72-
string eventKeyJmesPath,
73-
string payloadValidationJmesPath,
74-
bool throwOnNoIdempotencyKey,
75-
bool useLocalCache,
76-
int localCacheMaxItems,
77-
long expirationInSeconds,
78-
string hashFunction)
70+
/// <param name="builder">The builder containing the configuration values</param>
71+
internal IdempotencyOptions(IdempotencyOptionsBuilder builder)
7972
{
80-
EventKeyJmesPath = eventKeyJmesPath;
81-
PayloadValidationJmesPath = payloadValidationJmesPath;
82-
ThrowOnNoIdempotencyKey = throwOnNoIdempotencyKey;
83-
UseLocalCache = useLocalCache;
84-
LocalCacheMaxItems = localCacheMaxItems;
85-
ExpirationInSeconds = expirationInSeconds;
86-
HashFunction = hashFunction;
73+
EventKeyJmesPath = builder.EventKeyJmesPath;
74+
PayloadValidationJmesPath = builder.PayloadValidationJmesPath;
75+
ThrowOnNoIdempotencyKey = builder.ThrowOnNoIdempotencyKey;
76+
UseLocalCache = builder.UseLocalCache;
77+
LocalCacheMaxItems = builder.LocalCacheMaxItems;
78+
ExpirationInSeconds = builder.ExpirationInSeconds;
79+
HashFunction = builder.HashFunction;
80+
ResponseHook = builder.ResponseHook;
8781
}
8882
}

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

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,21 +43,59 @@ 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+
/// Gets the event key JMESPath expression.
52+
/// </summary>
53+
internal string EventKeyJmesPath => _eventKeyJmesPath;
54+
55+
/// <summary>
56+
/// Gets the payload validation JMESPath expression.
57+
/// </summary>
58+
internal string PayloadValidationJmesPath => _payloadValidationJmesPath;
59+
60+
/// <summary>
61+
/// Gets whether to throw exception if no idempotency key is found.
62+
/// </summary>
63+
internal bool ThrowOnNoIdempotencyKey => _throwOnNoIdempotencyKey;
64+
65+
/// <summary>
66+
/// Gets whether local cache is enabled.
67+
/// </summary>
68+
internal bool UseLocalCache => _useLocalCache;
69+
70+
/// <summary>
71+
/// Gets the maximum number of items in the local cache.
72+
/// </summary>
73+
internal int LocalCacheMaxItems => _localCacheMaxItems;
74+
75+
/// <summary>
76+
/// Gets the expiration in seconds.
77+
/// </summary>
78+
internal long ExpirationInSeconds => _expirationInSeconds;
79+
80+
/// <summary>
81+
/// Gets the hash function.
82+
/// </summary>
83+
internal string HashFunction => _hashFunction;
84+
85+
/// <summary>
86+
/// Gets the response hook function.
87+
/// </summary>
88+
internal Func<object, AWS.Lambda.Powertools.Idempotency.Persistence.DataRecord, object> ResponseHook => _responseHook;
89+
90+
/// <summary>
91+
/// Initialize and return an instance of IdempotencyOptions.
4792
/// Example:
48-
/// IdempotencyConfig.Builder().WithUseLocalCache().Build();
49-
/// This instance must then be passed to the Idempotency.Config:
50-
/// Idempotency.Config().WithConfig(config).Configure();
93+
/// new IdempotencyOptionsBuilder().WithUseLocalCache().Build();
94+
/// This instance can then be passed to Idempotency.Configure:
95+
/// Idempotency.Configure(builder => builder.WithOptions(options));
5196
/// </summary>
52-
/// <returns>an instance of IdempotencyConfig</returns>
53-
public IdempotencyOptions Build() =>
54-
new(_eventKeyJmesPath,
55-
_payloadValidationJmesPath,
56-
_throwOnNoIdempotencyKey,
57-
_useLocalCache,
58-
_localCacheMaxItems,
59-
_expirationInSeconds,
60-
_hashFunction);
97+
/// <returns>an instance of IdempotencyOptions</returns>
98+
public IdempotencyOptions Build() => new(this);
6199

62100
/// <summary>
63101
/// A JMESPath expression to extract the idempotency key from the event record.
@@ -133,4 +171,15 @@ public IdempotencyOptionsBuilder WithHashFunction(string hashFunction)
133171
#endif
134172
return this;
135173
}
174+
175+
/// <summary>
176+
/// Set a response hook function, to be called with the response and the data record.
177+
/// </summary>
178+
/// <param name="hook">The response hook function</param>
179+
/// <returns>the instance of the builder (to chain operations)</returns>
180+
public IdempotencyOptionsBuilder WithResponseHook(Func<object, AWS.Lambda.Powertools.Idempotency.Persistence.DataRecord, object> hook)
181+
{
182+
_responseHook = hook;
183+
return this;
184+
}
136185
}

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>

0 commit comments

Comments
 (0)