Skip to content

feat(smithy-core): auto-fill idempotency tokens before serialization - #913

Merged
rohangavankar merged 3 commits into
developfrom
rohangavankar/feat/idempotency-token-autofill
Nov 11, 2026
Merged

rohangavankar merged 3 commits into
developfrom
rohangavankar/feat/idempotency-token-autofill

Conversation

@rohangavankar

@rohangavankar rohangavankar commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Operations with an idempotency token now get one filled in automatically when the caller leaves it unset. The request pipeline fills it with a UUID v4 right after the whole modify_before_serialization interceptor chain, before the request is serialized, so it works for every protocol whether the token goes in the body, a header or the query string. This matches smithy-java, which fills a copy in modifyBeforeSerialization.

The caller's input is never changed: the token goes on a shallow copy, so reusing one input across calls sends a new token each call. Retries resend the same token since the request is only serialized once. A token set by the caller or by an interceptor is sent as is, and an interceptor that returns a new input still gets a token. Interceptors from read_before_serialization on, and logs, can see the generated token. Because the fill only happens in the pipeline, other serializations such as Document.from_shape(input) and types-only packages never get a token. Hand-written inputs that aren't dataclasses with the token field are sent unchanged, with a warning.

The generator is kept private, because a custom generator still can't dedupe across calls. Callers who need that should pass their own token.

This also turns on the idempotency token autofill protocol tests for restJson1, restXml, awsQuery and ec2Query.

Testing

Unit and protocol tests pass. I also generated a Secrets Manager client and ran it against the real service, where the token becomes the secret's version ID:

  • An unset token was filled with a UUID v4 that matched the version ID the service returned
  • Reusing one input for two calls sent two different tokens
  • A caller token was sent unchanged, and repeating the call with it did not create a new version

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@rohangavankar
rohangavankar requested a review from a team as a code owner November 10, 2026 18:55
kuhe
kuhe previously approved these changes Nov 10, 2026

@jonathan343 jonathan343 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @rohangavankar! I think these changes mostly look good.

I think our code generator should detect members with this trait and append additional document to describe the behavior. Botocore adds This field is autopopulated if not provided.. We could add something similar?

@jonathan343

Copy link
Copy Markdown
Contributor

I know we discussed the tradeoffs offline and decided on the serializer approach. After more thought, I think filling the token in the pipeline before serialization is better.

Both work, but serialize_members doesn't know whether it's building a request. So any serialization creates a token. For example, Document.from_shape(input) gives a new random clientToken each time, and types-only packages would too.

As you're aware, filling in the pipeline also:

  • Lets interceptors and logs see the token, so users don't have to parse the request for each protocol.
  • Matches smithy-java, which fills a copy in modifyBeforeSerialization.

It just needs to run after the whole modify_before_serialization chain, so an interceptor that rebuilds the input can't drop the token.

It's a two-way door, but cheapest to change before release. Did you look into this any more after our discussion?

lukasdchang
lukasdchang previously approved these changes Nov 10, 2026
@rohangavankar
rohangavankar force-pushed the rohangavankar/feat/idempotency-token-autofill branch from 9981a05 to 9d14e2b Compare November 10, 2026 20:16
@rohangavankar rohangavankar changed the title feat(smithy-core): auto-fill idempotency tokens during serialization feat(smithy-core): auto-fill idempotency tokens before serialization Nov 10, 2026
@rohangavankar
rohangavankar force-pushed the rohangavankar/feat/idempotency-token-autofill branch 3 times, most recently from 847676b to d97c886 Compare November 11, 2026 17:37
)


async def test_unset_idempotency_token_is_generated_once(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think a few of these could be merged:

  • test_unset_idempotency_token_is_generated_once and test_caller_input_is_not_mutated_by_token_fill check the same input and token.
  • test_set_idempotency_token_is_sent_unchanged and test_empty_string_idempotency_token_is_sent_unchanged could be one test parametrized on "caller-token" and "".
  • test_non_dataclass_input_is_passed_through and test_input_without_the_derived_field_is_passed_through both hit the "can't fill" path, but only the second checks the warning.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merged all three in 386e3be:

  • test_unset_idempotency_token_is_generated_once_on_a_copy now also checks the caller's input isn't mutated.
  • test_set_idempotency_token_is_sent_unchanged is parametrized on "caller-token" and "".
  • test_input_that_cannot_be_filled_is_passed_through_with_a_warning is parametrized on the non-dataclass input and the misnamed field, and checks the warning for both.

jonathan343
jonathan343 previously approved these changes Nov 11, 2026
jonathan343
jonathan343 previously approved these changes Nov 11, 2026
@rohangavankar
rohangavankar force-pushed the rohangavankar/feat/idempotency-token-autofill branch from 3fc6a99 to 386e3be Compare November 11, 2026 21:02
Operation inputs with an @idempotencyToken member now get a UUID v4 when the
caller leaves the member unset. The fill runs once per call in the shared
request pipeline, right after the whole modify_before_serialization
interceptor chain. So read_before_execution and modify_before_serialization see the caller's own
input, and an interceptor that returns a new input still gets a token.
Serialization runs once per call, so every retry attempt sends the same token.
A caller-supplied token is sent unchanged, and the caller's input object is
never mutated: a generated token goes on a copy. Inputs that aren't
dataclasses with the derived field, such as hand-written shapes, are passed
through unchanged, and a warning is logged. Sync and async clients both get this, since they share the
pipeline.

The token generator is private. Callers who need deduplication across calls
should pass their own token per call. A client-wide generator could not help
there, since it still gives each new call a new token. botocore and the JS v3
SDK hard-code a UUID v4, and the PHP SDK only lets callers swap the
random-byte source, so none of them exposes an option that returns the token
itself.

The Python field name of the token member is cached on APIOperation, so
operations without a token pay one attribute read per call.

The protocol test generator now pins the token for operations with an
@idempotencyToken member by patching the private generator. That lets the
QueryIdempotencyTokenAutoFill tests run for restJson1, restXml, awsQuery and
ec2Query, so they come off the skip lists.
Generated docs for an @idempotencyToken member of an operation input now end
with "This field is autopopulated if not provided.", matching botocore.
Members without docs get that sentence alone. Tokens outside operation
inputs are not filled by the client, so they get no note.

The Java generators share CodegenUtils.getMemberDocs, used for both the
.pyi stubs and eager models, and the Python generator applies the same rule.
The regenerated protocol-test stubs pick up the note.
- Fold the caller-input mutation check into the unset-token test, since both
  used the same input and token.
- Parametrize the caller-supplied token test on "caller-token" and "".
- Parametrize the can't-fill test on a non-dataclass input and a misnamed
  dataclass field, so the warning is checked for both.
@rohangavankar
rohangavankar force-pushed the rohangavankar/feat/idempotency-token-autofill branch from 386e3be to 0c6f659 Compare November 11, 2026 21:23
@rohangavankar
rohangavankar merged commit 91c2325 into develop Nov 11, 2026
26 checks passed
@jonathan343
jonathan343 deleted the rohangavankar/feat/idempotency-token-autofill branch November 11, 2026 21:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants