Documentation
¶
Overview ¶
Package idempotency makes a Lambda handler, or any function, idempotent: called again with the same input while the first call's record lasts, it returns the first call's result instead of running again. Retries of an event source, a client resending a request after a timeout, two invocations racing on the same order: the side effect happens once.
It is the Go side of Powertools for AWS Lambda (Python)'s idempotency utility, and writes the same records: a Go and a Python function can share a table, and even each other's records when they share a key prefix.
Getting started ¶
Wrap the handler, give it a Store, and choose the part of the event that identifies a request:
store, err := dynamodb.FromConfig(cfg, "IdempotencyTable")
if err != nil { ... }
handler := idempotency.Idempotent(createPayment, store,
idempotency.WithEventKeyJMESPath("powertools_json(body).[user_id, product_id]"))
lambda.Start(handler)
The same call wraps any other function func(context.Context, E) (R, error): E is the payload the key comes from (Python's data_keyword_argument), R the result. Use one for each record of a batch, with the record as E.
How it works ¶
Every call computes an idempotency key, "<prefix>#<hash>", and writes an INPROGRESS record under it with a conditional write. When the write succeeds the function runs, and its result is saved as a COMPLETED record that lasts an hour (WithExpiresAfter). When a record already holds the key:
- a COMPLETED one answers the call with its stored result, decoded into R, after WithResponseHook if set;
- an INPROGRESS one fails the call with an AlreadyInProgressError (ErrAlreadyInProgress), which is safe to retry;
- an INPROGRESS one whose invocation ran past its deadline, or any expired one, is replaced and the function runs again.
The in-progress record expires with the invocation: its deadline, from the context Lambda passes (ctx.Deadline), is stored with it, so that an invocation that times out does not lock its key until the record expires.
When the function returns an error, the context's cancellation included, the record is deleted and the error returned, so that a retry runs it again; a panic deletes the record and goes on. Errors of the store are PersistenceErrors (ErrPersistence).
The idempotency key ¶
The payload is marshalled with encoding/json/v2; WithEventKeyJMESPath selects part of it with a JMESPath expression (the Powertools functions, such as powertools_json, are available, and WithJMESPathFunctions adds more), the whole payload otherwise. The selection is hashed with MD5 (WithHashFunction picks another of Python's hashlib names, WithHash any hash.Hash) after rendering it as Python's json.dumps(sort_keys=True) does, so that both runtimes compute the same hash.
The prefix is, as in Python, the Lambda function's name (AWS_LAMBDA_FUNCTION_NAME, "test-func" outside Lambda) and the Go function's qualified name from the runtime, such as "orders.github.com/me/app/payments.charge". Names of closures are positional ("main.main.func1"): give those a stable prefix with WithKeyPrefix, which also makes the key independent of renames.
A payload whose selection is empty (null, "", an empty object or array, an object or array of nulls) has no key: the function runs without idempotency, after a warning, or fails with ErrNoIdempotencyKey under WithKeyRequired. 0 and false are keys.
Options ¶
- WithEventKeyJMESPath: the part of the payload the key is made of.
- WithPayloadValidationJMESPath: the part of the payload that must not change for the same key (ErrPayloadValidation otherwise).
- WithJMESPathFunctions: custom JMESPath functions.
- WithKeyRequired: a missing key is an error (raise_on_no_idempotency_key).
- WithExpiresAfter: how long records last, an hour by default.
- WithLocalCache, WithLocalCacheMaxItems: keep COMPLETED records in memory too (256 by default), per function and execution environment.
- WithHashFunction, WithHash: the hash, MD5 by default.
- WithKeyPrefix: the key's prefix.
- WithResponseHook: rewrite replayed responses.
Idempotent panics with ErrInvalidConfig when an option cannot work (an expression that does not compile, an unknown hash name, a hook for another result type): build idempotent functions in main or at package level.
Environment variables ¶
- POWERTOOLS_IDEMPOTENCY_DISABLED: a true value calls the function directly, for local development and tests. It warns once.
- AWS_LAMBDA_FUNCTION_NAME: the first part of the default key prefix.
Stores ¶
Package idempotency/dynamodb stores records in DynamoDB, package idempotency/redis in Redis, Valkey or any Redis-compatible cache (ElastiCache, MemoryDB), with Python's schemas. MemoryStore keeps them in memory, for tests. Implement Store for anything else: it only moves records, while Idempotent owns keys, validation, the cache and the status logic, so one store serves any number of functions with different options.
Differences from Python ¶
- One function, Idempotent, covers both decorators. The result is stored as JSON and decoded into R, so output serializers are not needed; R must round-trip through encoding/json/v2.
- The payload is the typed E marshalled to JSON: fields E does not declare do not take part in the key. Take jsontext.Value as E to hash the raw event, byte for byte as Python sees it.
- Numbers: JSON integers and floats hash as in Python. JMESPath, though, works on float64 in Go, so a selected number that is integral hashes as an integer even if the event wrote 10.0 (Python hashes 10.0), and an integer beyond 2^53 loses precision. Hash such values as strings.
- The remaining time comes from the context's deadline; there is no register_lambda_context.
- Options belong to each idempotent function, not to the store (Python keeps the first configuration a store sees).
- Fixed: stored responses that are empty or zero ({}, [], 0, "", false) replay as such, not as None; with a sort key, records read back keep their idempotency key, so the local cache finds them.
- The local cache is safe for concurrent invocations (Lambda Managed Instances).
- Durable function replays and the boto3 user agent tag are not ported.
Index ¶
- Constants
- Variables
- func Idempotent[E, R any](fn func(context.Context, E) (R, error), store Store, opts ...Option) func(context.Context, E) (R, error)
- type AlreadyInProgressError
- type InvalidStatusError
- type ItemAlreadyExistsError
- type MemoryStore
- func (s *MemoryStore) DeleteRecord(_ context.Context, key string) error
- func (s *MemoryStore) GetRecord(_ context.Context, key string) (*Record, error)
- func (s *MemoryStore) PutRecord(_ context.Context, record Record, now time.Time) error
- func (s *MemoryStore) UpdateRecord(_ context.Context, record Record) error
- type Option
- func WithEventKeyJMESPath(expression string) Option
- func WithExpiresAfter(d time.Duration) Option
- func WithHash(newHash func() hash.Hash) Option
- func WithHashFunction(name string) Option
- func WithJMESPathFunctions(functions ...jmespath.Function) Option
- func WithKeyPrefix(prefix string) Option
- func WithKeyRequired(required bool) Option
- func WithLocalCache(enabled bool) Option
- func WithLocalCacheMaxItems(n int) Option
- func WithPayloadValidationJMESPath(expression string) Option
- func WithResponseHook[R any](hook ResponseHook[R]) Option
- type PersistenceError
- type Record
- type ResponseHook
- type Status
- type Store
Examples ¶
Constants ¶
const ( OpPut = "put" OpGet = "get" OpUpdate = "update" OpDelete = "delete" )
The operations a PersistenceError reports.
const DefaultExpiresAfter = time.Hour
DefaultExpiresAfter is how long a record lasts unless WithExpiresAfter says otherwise, as in Powertools Python.
const DefaultHashFunction = "md5"
DefaultHashFunction is the hash of idempotency keys and payload validation unless WithHashFunction or WithHash says otherwise, as in Powertools Python.
const DefaultLocalCacheMaxItems = 256
DefaultLocalCacheMaxItems is the capacity of the local cache unless WithLocalCacheMaxItems says otherwise, as in Powertools Python.
Variables ¶
var ( // ErrAlreadyInProgress reports an invocation with the same idempotency // key that has not finished yet. It is safe to retry later. ErrAlreadyInProgress = errors.New("execution already in progress") // ErrPayloadValidation reports a payload whose validated part differs // from the one stored with the record of the same idempotency key. ErrPayloadValidation = errors.New("payload does not match stored record for this event key") // ErrNoIdempotencyKey reports a payload without an idempotency key, when // WithKeyRequired is on. ErrNoIdempotencyKey = errors.New("no data found to create a hashed idempotency key") // ErrInconsistentState reports a record that changed between the reads // of one invocation more times than the retries absorb. ErrInconsistentState = errors.New("idempotency store returned inconsistent results") // ErrInvalidStatus reports a stored record with a status Powertools does // not write. ErrInvalidStatus = errors.New("invalid idempotency record status") // ErrPersistence is what every PersistenceError matches. ErrPersistence = errors.New("idempotency store failure") // ErrInvalidConfig reports options or store settings that cannot work. // Idempotent panics with it, stores' constructors return it. ErrInvalidConfig = errors.New("invalid idempotency configuration") )
Errors an idempotent function returns.
var ( // ErrItemAlreadyExists is what PutRecord returns when a record holds the // key. Return an ItemAlreadyExistsError instead when the record is known. ErrItemAlreadyExists = errors.New("idempotency record already exists") // ErrItemNotFound is what GetRecord returns when no record has the key. ErrItemNotFound = errors.New("idempotency record not found") // ErrMalformedRecord reports stored data that is not an idempotency // record. ErrMalformedRecord = errors.New("malformed idempotency record") )
Errors of the Store contract, for those who write a Store.
Functions ¶
func Idempotent ¶
func Idempotent[E, R any](fn func(context.Context, E) (R, error), store Store, opts ...Option) func(context.Context, E) (R, error)
Idempotent makes fn idempotent: called again with a payload that has the same idempotency key while the record of the first call lasts, it returns the first call's result instead of running fn again.
fn is a Lambda handler or any other function; event is the payload the idempotency key comes from. The result is stored as JSON and decoded back into an R on replays, so R must survive a round trip through encoding/json/v2 (exported fields, no channels or functions); no output serializer is needed.
While fn runs, a concurrent call with the same key fails with an AlreadyInProgressError. When fn returns an error, the context's cancellation included, or panics, the in-progress record is deleted, so that a retry runs fn again; the error is returned and the panic goes on. Store failures are PersistenceErrors.
Idempotent checks its options when it is called, before any invocation, and panics with an error matching ErrInvalidConfig when they cannot work: call it in main or at package level. The returned function is safe for concurrent use.
Example ¶
package main
import (
"context"
"fmt"
"time"
"github.com/alessandrolattao/powertools-lambda-go/idempotency"
)
type payment struct {
UserID string `json:"user_id"`
ProductID string `json:"product_id"`
Amount float64 `json:"amount"`
}
type paymentResult struct {
PaymentID string `json:"payment_id"`
Message string `json:"message"`
}
func main() {
// In a Lambda function the store is DynamoDB or Redis; see the
// idempotency/dynamodb and idempotency/redis packages.
store := idempotency.NewMemoryStore()
charges := 0
handler := func(context.Context, payment) (paymentResult, error) {
charges++
return paymentResult{PaymentID: fmt.Sprintf("pay-%d", charges), Message: "success"}, nil
}
// Only the user and the product make a payment unique.
charge := idempotency.Idempotent(handler, store,
idempotency.WithEventKeyJMESPath("[user_id, product_id]"))
// A Lambda function hands charge to lambda.Start.
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
for _, p := range []payment{
{UserID: "u-1", ProductID: "p-1", Amount: 10},
{UserID: "u-1", ProductID: "p-1", Amount: 10}, // a retry: no second charge
{UserID: "u-1", ProductID: "p-2", Amount: 10},
} {
result, err := charge(ctx, p)
if err != nil {
panic(err)
}
fmt.Println(p.ProductID, result.PaymentID)
}
fmt.Println("charges:", charges)
}
Output: p-1 pay-1 p-1 pay-1 p-2 pay-2 charges: 2
Types ¶
type AlreadyInProgressError ¶
type AlreadyInProgressError struct {
// Key is the idempotency key.
Key string
// KeyExpression is the event key JMESPath expression, empty when the key
// is the hash of the whole payload.
KeyExpression string
}
AlreadyInProgressError reports an invocation with the same idempotency key that has not finished yet. It matches ErrAlreadyInProgress.
func (*AlreadyInProgressError) Error ¶
func (e *AlreadyInProgressError) Error() string
func (*AlreadyInProgressError) Is ¶
func (*AlreadyInProgressError) Is(target error) bool
Is makes every AlreadyInProgressError match ErrAlreadyInProgress.
type InvalidStatusError ¶
type InvalidStatusError struct {
Status Status
}
InvalidStatusError reports a stored record with a status Powertools does not write. It matches ErrInvalidStatus.
func (*InvalidStatusError) Error ¶
func (e *InvalidStatusError) Error() string
func (*InvalidStatusError) Is ¶
func (*InvalidStatusError) Is(target error) bool
Is makes every InvalidStatusError match ErrInvalidStatus.
type ItemAlreadyExistsError ¶
type ItemAlreadyExistsError struct {
// Old is the record that holds the key, nil when unknown.
Old *Record
}
ItemAlreadyExistsError is what PutRecord returns when a record holds the key and the store knows it without another read (DynamoDB returns it with the failed conditional write): carrying it saves a GetRecord. It matches ErrItemAlreadyExists.
func (*ItemAlreadyExistsError) Error ¶
func (e *ItemAlreadyExistsError) Error() string
func (*ItemAlreadyExistsError) Is ¶
func (*ItemAlreadyExistsError) Is(target error) bool
Is makes every ItemAlreadyExistsError match ErrItemAlreadyExists.
type MemoryStore ¶
type MemoryStore struct {
// contains filtered or unexported fields
}
MemoryStore is a Store that keeps records in memory. It serves tests, and functions that only need idempotency within one execution environment; records do not survive the environment and are not shared between environments, so it does not deduplicate a Lambda function's invocations in general. Its zero value is ready to use.
func NewMemoryStore ¶
func NewMemoryStore() *MemoryStore
NewMemoryStore returns an empty MemoryStore.
func (*MemoryStore) DeleteRecord ¶
func (s *MemoryStore) DeleteRecord(_ context.Context, key string) error
DeleteRecord implements Store.
func (*MemoryStore) UpdateRecord ¶
func (s *MemoryStore) UpdateRecord(_ context.Context, record Record) error
UpdateRecord implements Store.
type Option ¶
type Option func(*config)
Option configures Idempotent.
func WithEventKeyJMESPath ¶
WithEventKeyJMESPath selects the part of the payload the idempotency key is the hash of, with a JMESPath expression that may use the Powertools functions (powertools_json and the others). By default the key is the hash of the whole payload.
A payload that carries JSON in a string field (an API Gateway body) should go through powertools_json, so that the key does not depend on how the JSON is spaced: "powertools_json(body).[user_id, product_id]".
func WithExpiresAfter ¶
WithExpiresAfter sets how long a record lasts, in whole seconds, after which the same idempotency key runs the function again. It defaults to an hour.
func WithHash ¶
WithHash uses newHash for idempotency keys and payload validation, for a hash WithHashFunction does not name. Records are keyed by its hex digest.
func WithHashFunction ¶
WithHashFunction picks the hash of idempotency keys and payload validation by its name in Python's hashlib: md5 (the default), sha1, sha224, sha256, sha384, sha512, sha3_224, sha3_256, sha3_384, sha3_512, blake2b, blake2s.
func WithJMESPathFunctions ¶
WithJMESPathFunctions makes custom functions available to the event key and payload validation expressions, next to the built-in and the Powertools ones.
func WithKeyPrefix ¶
WithKeyPrefix replaces the first part of the idempotency key, the Lambda function's and the Go function's names, so that keys survive renaming the function, or match the keys of another function that shares the store. The key becomes "<prefix>#<hash>". Changing it makes existing records unreachable.
Example ¶
package main
import (
"context"
"fmt"
"time"
"github.com/alessandrolattao/powertools-lambda-go/idempotency"
)
func main() {
type subscription struct {
UserID int `json:"user_id"`
}
store := idempotency.NewMemoryStore()
subscribe := idempotency.Idempotent(func(context.Context, subscription) (string, error) {
return "subscribed", nil
}, store, idempotency.WithKeyPrefix("my_custom_prefix"), idempotency.WithEventKeyJMESPath("user_id"))
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
if _, err := subscribe(ctx, subscription{UserID: 1}); err != nil {
panic(err)
}
// The key is the prefix and the MD5 of the selected JSON, 1 here: the
// key a Python function with the same prefix computes.
record, err := store.GetRecord(ctx, "my_custom_prefix#c4ca4238a0b923820dcc509a6f75849b")
if err != nil {
panic(err)
}
fmt.Println(record.Status, record.ResponseData)
}
Output: COMPLETED "subscribed"
func WithKeyRequired ¶
WithKeyRequired makes a payload without an idempotency key an error (ErrNoIdempotencyKey) instead of a call that runs without idempotency. Python calls it raise_on_no_idempotency_key.
func WithLocalCache ¶
WithLocalCache keeps the COMPLETED records in memory too, so that replays within one execution environment skip the store. The cache is local to the execution environment and to the idempotent function; it is off by default, since responses may be large relative to the function's memory.
func WithLocalCacheMaxItems ¶
WithLocalCacheMaxItems sets how many records the local cache holds before it evicts the least recently used one. It defaults to 256.
func WithPayloadValidationJMESPath ¶
WithPayloadValidationJMESPath turns payload validation on: the part of the payload expression selects is hashed into the record, and a later call with the same idempotency key but a different such part fails with ErrPayloadValidation instead of replaying a response meant for another payload.
func WithResponseHook ¶
func WithResponseHook[R any](hook ResponseHook[R]) Option
WithResponseHook runs hook on every replay. R must be the result type of the function Idempotent wraps.
Example ¶
package main
import (
"context"
"fmt"
"time"
"github.com/alessandrolattao/powertools-lambda-go/idempotency"
)
type payment struct {
UserID string `json:"user_id"`
ProductID string `json:"product_id"`
Amount float64 `json:"amount"`
}
func main() {
process := idempotency.Idempotent(func(context.Context, payment) (map[string]string, error) {
return map[string]string{"payment_id": "pay-1"}, nil
}, idempotency.NewMemoryStore(), idempotency.WithResponseHook(
func(_ context.Context, response map[string]string, _ idempotency.Record) (map[string]string, error) {
response["x-idempotent-replay"] = "true"
return response, nil
}))
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
for range 2 {
response, err := process(ctx, payment{UserID: "u-1"})
if err != nil {
panic(err)
}
fmt.Println(response)
}
}
Output: map[payment_id:pay-1] map[payment_id:pay-1 x-idempotent-replay:true]
type PersistenceError ¶
type PersistenceError struct {
// Op is OpPut, OpGet, OpUpdate or OpDelete.
Op string
// Err is the cause.
Err error
}
PersistenceError reports a store operation that failed. The function's own errors are never wrapped in it, except when deleting the in-progress record after the function failed fails too: then both are joined. It matches ErrPersistence.
func (*PersistenceError) Error ¶
func (e *PersistenceError) Error() string
Error words the failure as Powertools Python does.
func (*PersistenceError) Is ¶
func (*PersistenceError) Is(target error) bool
Is makes every PersistenceError match ErrPersistence.
func (*PersistenceError) Unwrap ¶
func (e *PersistenceError) Unwrap() error
Unwrap returns the cause.
type Record ¶
type Record struct {
// Key is the idempotency key, "<prefix>#<hash>".
Key string
// Status is INPROGRESS while the function runs, COMPLETED once it
// returned successfully.
Status Status
// ExpiresAt is when the record stops counting, in whole seconds. After
// it, the same key runs the function again.
ExpiresAt time.Time
// InProgressExpiresAt is when an INPROGRESS record stops locking its key,
// in milliseconds: the deadline of the invocation that wrote it, so that
// a timed-out invocation does not lock the key until ExpiresAt. It is
// zero when the deadline was unknown.
InProgressExpiresAt time.Time
// ResponseData is the JSON the function returned, on COMPLETED records.
ResponseData string
// PayloadHash is the hash of the part of the payload that payload
// validation compares, empty when validation is off.
PayloadHash string
}
Record is an idempotency record: what a Store keeps for one idempotency key. Its fields map one to one onto the attributes Powertools Python writes, so that both runtimes can read each other's records.
func (Record) HoldsKey ¶
HoldsKey reports whether the record still holds its key at now, so that a new INPROGRESS record must not replace it: it has not expired, and it is not an INPROGRESS record whose invocation ran past its deadline. A Store's PutRecord fails exactly when the record it holds for the key HoldsKey.
type ResponseHook ¶
ResponseHook rewrites the response of a replay: it receives the response decoded from the stored record and the record itself, and returns what the idempotent function returns. It runs only when a COMPLETED record answers the call, never on the first execution or on errors. An error it returns is what the call returns.
type Store ¶
type Store interface {
// PutRecord saves record, an INPROGRESS record, unless the store holds a
// record with the same key that HoldsKey at now: then it saves nothing
// and returns ErrItemAlreadyExists, or an ItemAlreadyExistsError with
// the record when it has it at hand. The check and the write must be one
// atomic step (a conditional write), as that is what keeps two
// concurrent invocations from both running the function.
PutRecord(ctx context.Context, record Record, now time.Time) error
// GetRecord returns the record with key, or ErrItemNotFound. It must
// read the latest write (a strongly consistent read).
GetRecord(ctx context.Context, key string) (*Record, error)
// UpdateRecord saves record, a COMPLETED record, over the INPROGRESS one
// with the same key.
UpdateRecord(ctx context.Context, record Record) error
// DeleteRecord removes the record with key, if any.
DeleteRecord(ctx context.Context, key string) error
}
Store is where idempotency records persist: the persistence layer. DynamoDB (package idempotency/dynamodb) and Redis or Valkey (package idempotency/redis) come with Powertools; implement Store to bring another one.
A Store only moves records in and out. Keys, hashes, payload validation, the local cache and the status machine are the business of Idempotent, so a Store has no configuration in common with it and can serve any number of idempotent functions at once. Its methods are called concurrently.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package dynamodb keeps idempotency records in Amazon DynamoDB: the persistence layer Powertools Python calls DynamoDBPersistenceLayer, with the same item schema and the same conditional writes, so that Go and Python functions can share a table.
|
Package dynamodb keeps idempotency records in Amazon DynamoDB: the persistence layer Powertools Python calls DynamoDBPersistenceLayer, with the same item schema and the same conditional writes, so that Go and Python functions can share a table. |
|
Package redis keeps idempotency records in Valkey, Redis OSS or any Redis-compatible cache (Amazon ElastiCache, Amazon MemoryDB): the persistence layer Powertools Python calls CachePersistenceLayer, with the same record format, so that Go and Python functions can share a cache.
|
Package redis keeps idempotency records in Valkey, Redis OSS or any Redis-compatible cache (Amazon ElastiCache, Amazon MemoryDB): the persistence layer Powertools Python calls CachePersistenceLayer, with the same record format, so that Go and Python functions can share a cache. |