Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package software.amazon.smithy.python.aws.codegen;

import java.util.List;
import java.util.Set;
import software.amazon.smithy.aws.traits.ServiceTrait;
import software.amazon.smithy.python.codegen.GenerationContext;
import software.amazon.smithy.python.codegen.RuntimeTypes;
import software.amazon.smithy.python.codegen.integrations.PythonIntegration;
import software.amazon.smithy.python.codegen.sections.InitRetryStrategyResolverSection;
import software.amazon.smithy.python.codegen.writer.PythonWriter;
import software.amazon.smithy.utils.CodeInterceptor;
import software.amazon.smithy.utils.CodeSection;

/**
* Injects DynamoDB's default retry options (max attempts 4, 25ms non-throttling
* base backoff).
*/
public final class AwsDynamoDbRetryIntegration implements PythonIntegration {

private static final Set<String> DYNAMODB_SDK_IDS = Set.of("DynamoDB", "DynamoDB Streams");

private static final double DYNAMODB_BASE_BACKOFF_SECONDS = 0.025;
private static final int DYNAMODB_MAX_ATTEMPTS = 4;

private static boolean isDynamoDb(GenerationContext context) {
return context.settings()
.service(context.model())
.getTrait(ServiceTrait.class)
.map(trait -> DYNAMODB_SDK_IDS.contains(trait.getSdkId()))
.orElse(false);
}

@Override
public List<? extends CodeInterceptor<? extends CodeSection, PythonWriter>> interceptors(
GenerationContext context
) {
if (!isDynamoDb(context)) {
return List.of();
}
return List.of(new DynamoDbRetryStrategyResolverInterceptor());
}

private static final class DynamoDbRetryStrategyResolverInterceptor
implements CodeInterceptor<InitRetryStrategyResolverSection, PythonWriter> {

@Override
public Class<InitRetryStrategyResolverSection> sectionType() {
return InitRetryStrategyResolverSection.class;
}

@Override
public void write(PythonWriter writer, String previousText, InitRetryStrategyResolverSection section) {
writer.write(
"self._retry_strategy_resolver = $T(default_max_attempts=$L, default_backoff_scale=$L)",
RuntimeTypes.RETRY_STRATEGY_RESOLVER,
DYNAMODB_MAX_ATTEMPTS,
DYNAMODB_BASE_BACKOFF_SECONDS);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package software.amazon.smithy.python.aws.codegen;

import java.util.Map;
import java.util.Set;
import software.amazon.smithy.aws.traits.ServiceTrait;
import software.amazon.smithy.model.Model;
import software.amazon.smithy.model.shapes.OperationShape;
import software.amazon.smithy.model.shapes.ServiceShape;
import software.amazon.smithy.python.codegen.integrations.PythonIntegration;

/**
* Marks the known long-polling operations so the generic client generator
* applies long-polling retry behavior to them.
*
* <p>These operations are hard-coded until the {@code aws.api#longPoll} trait
* ships in service models. Once it ships, this can check for the trait instead.
*/
public final class AwsLongPollingIntegration implements PythonIntegration {

private static final Map<String, Set<String>> LONG_POLLING_OPERATIONS = Map.of(

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.

[Question, maybe blocking?]

Does it really make sense for us to do this now before the trait is ready? I'd rather not have to track the separate work of updating this generator to function based on the trait and just update it when the rest of the SDKs do.

If we are already committed to shipping this, it's fine, but it is extra effort on our part for no customer benefit.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Here's what the SEP says:

IsLongPollingOperation      

This step determines whether the service operation in use is a long-polling operation. Long-polling operations MUST always back off when Retryable is true, even if HasRetryQuota is false.      

An operation is considered long-polling if it includes the aws.api#longPoll Smithy trait. (longPoll in C2J: "longPoll": {...}").      

Work to enable this trait is currently in progress, so until the trait is available, SDKs SHOULD hard-code the following combinations of service and operation as having this trait unless it requires significant wasted effort to do so.      

I took a look at other smithy SDKs and almost all of them are hardcoding the table now. I also found the trait has since shipped in smithy-lang/smithy#3019, though as smithy.api#longPoll rather than aws.api#longPoll.

However, I checked the service models: none of the operations carry the trait yet.

Also, given that:

  • long polling is a "SHOULD" rather than a "MUST" in SEP, and
  • the SEP also allows an exception: "... unless it requires significant wasted effort to do so" (I assume we fall into this case a bit?), and
  • we haven't released these clients yet,

I don't mind punting long polling to a follow-up. I agree that it is extra effort on our part for no customer benefit. If it also makes sense to you, then I'll drop the changes for long polling in this PR. Let me know what you think!

"SQS",
Set.of("ReceiveMessage"),
"SFN",
Set.of("GetActivityTask"),
"SWF",
Set.of("PollForActivityTask", "PollForDecisionTask"));

@Override
public boolean isLongPollingOperation(Model model, ServiceShape service, OperationShape operation) {
return service.getTrait(ServiceTrait.class)
.map(trait -> LONG_POLLING_OPERATIONS.get(trait.getSdkId()))
.map(operations -> operations.contains(operation.getId().getName()))
.orElse(false);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ software.amazon.smithy.python.aws.codegen.AwsProtocolsIntegration
software.amazon.smithy.python.aws.codegen.AwsServiceIdIntegration
software.amazon.smithy.python.aws.codegen.AwsUserAgentIntegration
software.amazon.smithy.python.aws.codegen.AwsStandardRegionalEndpointsIntegration
software.amazon.smithy.python.aws.codegen.AwsDynamoDbRetryIntegration
software.amazon.smithy.python.aws.codegen.AwsLongPollingIntegration
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import software.amazon.smithy.model.traits.StringTrait;
import software.amazon.smithy.python.codegen.integrations.PythonIntegration;
import software.amazon.smithy.python.codegen.integrations.RuntimeClientPlugin;
import software.amazon.smithy.python.codegen.sections.InitRetryStrategyResolverSection;
import software.amazon.smithy.python.codegen.writer.PythonWriter;
import software.amazon.smithy.utils.SmithyInternalApi;

Expand Down Expand Up @@ -86,13 +87,13 @@ def __init__(self, config: $1T | None = None, plugins: list[$2T] | None = None):
for plugin in client_plugins:
plugin(self._config)
self._retry_strategy_resolver = $5T()
$5C
""",
configSymbol,
pluginSymbol,
writer.consumer(w -> writeConstructorDocs(w, serviceSymbol.getName())),
writer.consumer(w -> writeDefaultPlugins(w, defaultPlugins)),
RuntimeTypes.RETRY_STRATEGY_RESOLVER);
writer.consumer(this::writeRetryStrategyResolverInit));

var topDownIndex = TopDownIndex.of(model);
var eventStreamIndex = EventStreamIndex.of(model);
Expand All @@ -113,6 +114,22 @@ private void writeDefaultPlugins(PythonWriter writer, Collection<SymbolReference
}
}

private void writeRetryStrategyResolverInit(PythonWriter writer) {
// Wrap this in a section so AWS integrations can inject service-specific retry defaults.
writer.pushState(new InitRetryStrategyResolverSection());
writer.write("self._retry_strategy_resolver = $T()", RuntimeTypes.RETRY_STRATEGY_RESOLVER);
writer.popState();
}

private boolean isLongPollingOperation(OperationShape operation) {
for (PythonIntegration integration : context.integrations()) {
if (integration.isLongPollingOperation(model, service, operation)) {
return true;
}
}
return false;
}

private void writeConstructorDocs(PythonWriter writer, String clientName) {
writer.writeMultiLineDocs(() -> {
writer.write("""
Expand Down Expand Up @@ -210,6 +227,7 @@ private void writeSharedOperationInit(
}

writer.putContext("operation", symbolProvider.toSymbol(operation));
writer.putContext("isLongPolling", isLongPollingOperation(operation));
writer.addStdlibImport("copy", "deepcopy");

writer.write("""
Expand Down Expand Up @@ -240,7 +258,8 @@ private void writeSharedOperationInit(
auth_scheme_resolver=config.auth_scheme_resolver,
supported_auth_schemes=config.auth_schemes,
endpoint_resolver=config.endpoint_resolver,
retry_strategy=retry_strategy,
retry_strategy=retry_strategy,${?isLongPolling}
is_long_polling=True,${/isLongPolling}

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.

[blocking, change requested]

This information is retry specific. Plumbing it through every operation call works fine in this instance, but this type of solution isn't scalable. This method signature would grow very quickly if we needed to update the pipeline method's signature for every branching decision that we make as part of the pipeline call.

We should talk about the right way to plumb through the information. We already have the APIOperation object in scope. Since this will eventually be a modeled trait, doesn't it make more sense for us to add it to the operation object as opposed to growing the pipeline method's signature?

)
""",
writer.consumer(w -> writeDefaultPlugins(w, defaultPlugins)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import java.util.List;
import software.amazon.smithy.codegen.core.SmithyIntegration;
import software.amazon.smithy.model.Model;
import software.amazon.smithy.model.shapes.OperationShape;
import software.amazon.smithy.model.shapes.ServiceShape;
import software.amazon.smithy.python.codegen.GenerationContext;
import software.amazon.smithy.python.codegen.PythonSettings;
import software.amazon.smithy.python.codegen.generators.ProtocolGenerator;
Expand Down Expand Up @@ -44,6 +46,21 @@ default Model preprocessModel(Model model, PythonSettings settings) {
return model;
}

/**
* Determines whether the given operation is a long-polling operation, which
* must back off before returning even when the retry quota is exhausted. AWS
* integrations use this hook to identify these operations while the
* {@code aws.api#longPoll} trait is not yet shipped in service models.
*
* @param model Model the operation belongs to.
* @param service Service the operation belongs to.
* @param operation Operation to test.
* @return Returns true if the operation is a long-polling operation.
*/
default boolean isLongPollingOperation(Model model, ServiceShape service, OperationShape operation) {
return false;
}

/**
* Writes out all extra files required by runtime plugins.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package software.amazon.smithy.python.codegen.sections;

import software.amazon.smithy.utils.CodeSection;
import software.amazon.smithy.utils.SmithyInternalApi;

/**
* A section that controls constructing the client's {@code RetryStrategyResolver}.
*/
@SmithyInternalApi
public record InitRetryStrategyResolverSection() implements CodeSection {}
10 changes: 7 additions & 3 deletions designs/retries.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,15 @@ class RetryStrategy(Protocol):
"""Upper limit on total attempt count (initial attempt plus retries)."""

async def acquire_initial_retry_token(
self, *, token_scope: str | None = None
self, *, token_scope: str | None = None, is_long_polling: bool = False
) -> RetryToken:
"""Called before any retries (for the first attempt at the operation).

:param token_scope: An arbitrary string accepted by the retry strategy to
separate tokens into scopes.
:param is_long_polling: Whether the operation is a long-polling operation.
Long-polling operations must back off before returning even when the
retry quota is exhausted.
:returns: A retry token, to be used for determining the retry delay, refreshing
the token after a failure, and recording success after success.
:raises RetryError: If the retry strategy has no available tokens.
Expand Down Expand Up @@ -110,8 +113,9 @@ class HasFault(Protocol):

`RetryStrategy` implementations MUST raise a `RetryError` if they receive an
exception where `is_retry_safe` is `False` and SHOULD raise a `RetryError` if it
is `None`. `RetryStrategy` implementations SHOULD use a delay that is at least
as long as `retry_after` but MAY choose to wait longer.
is `None`. `RetryStrategy` implementations SHOULD take `retry_after` into account
when computing the delay, but MAY adjust it (for example, by clamping it to an
upper bound).

### Backoff Strategy

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "feature",
"description": "Added support for the `x-amz-retry-after` response header in the `awsQuery` protocol."
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ def create_aws_query_error(
wrapper_elements: tuple[str, ...],
status: int,
context: TypedProperties,
retry_after: float | None = None,
) -> CallError:
"""Create a modeled or generic CallError from an awsQuery error response."""
code = _parse_aws_query_error_code(body, wrapper_elements)
Expand All @@ -121,7 +122,10 @@ def create_aws_query_error(
deserializer = XMLCodec().create_deserializer(
body, wrapper_elements=wrapper_elements
)
return error_shape.deserialize(deserializer)
modeled_error = error_shape.deserialize(deserializer)
if retry_after is not None:
modeled_error.retry_after = retry_after
return modeled_error

message = f"Unknown error for operation {operation.schema.id} - status: {status}"
if code is not None:
Expand All @@ -137,4 +141,5 @@ def create_aws_query_error(
is_throttling_error=is_throttle,
is_timeout_error=is_timeout,
is_retry_safe=is_throttle or is_timeout or None,
retry_after=retry_after,
)
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@
from smithy_http import tuples_to_fields
from smithy_http.aio import HTTPRequest as _HTTPRequest
from smithy_http.aio.interfaces import HTTPErrorIdentifier, HTTPRequest, HTTPResponse
from smithy_http.aio.protocols import HttpBindingClientProtocol, HttpClientProtocol
from smithy_http.aio.protocols import (
HttpBindingClientProtocol,
HttpClientProtocol,
parse_retry_after,
)
from smithy_http.deserializers import HTTPResponseDeserializer

from .._private.query.errors import (
Expand Down Expand Up @@ -353,6 +357,7 @@ async def _create_error(
wrapper_elements=self._error_wrapper_elements(),
status=response.status,
context=context,
retry_after=parse_retry_after(response),
)

def _action_name(
Expand Down
43 changes: 42 additions & 1 deletion packages/smithy-aws-core/tests/unit/test_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@
# SPDX-License-Identifier: Apache-2.0
from dataclasses import dataclass
from io import BytesIO
from typing import Any, cast
from unittest.mock import Mock

from smithy_aws_core._private.query.errors import create_aws_query_error
from smithy_aws_core._private.query.serializers import QueryShapeSerializer
from smithy_core.documents import TypeRegistry
from smithy_core.prelude import STRING
from smithy_core.schemas import Schema
from smithy_core.schemas import APIOperation, Schema
from smithy_core.serializers import ShapeSerializer
from smithy_core.shapes import ShapeID, ShapeType
from smithy_core.traits import XMLFlattenedTrait, XMLNameTrait
from smithy_core.types import TypedProperties


def test_query_list_serialization() -> None:
Expand Down Expand Up @@ -280,3 +285,39 @@ def serialize_members(self, serializer: ShapeSerializer) -> None:
Outer(inner=Inner("x")).serialize(serializer)

assert params == [("inner.value", "x")]


def _error_test_operation() -> APIOperation[Any, Any]:
operation = Mock(spec=APIOperation)
operation.schema = Schema(
id=ShapeID("com.example#TestOp"), shape_type=ShapeType.OPERATION
)
operation.error_schemas = []
return cast("APIOperation[Any, Any]", operation)


def test_aws_query_error_sets_retry_after_on_generic_error() -> None:
error = create_aws_query_error(
body=b"",
operation=_error_test_operation(),
error_registry=TypeRegistry({}),
default_namespace="com.example",
wrapper_elements=("ErrorResponse", "Error"),
status=503,
context=TypedProperties(),
retry_after=1.5,
)
assert error.retry_after == 1.5


def test_aws_query_error_retry_after_none_by_default() -> None:
error = create_aws_query_error(
body=b"",
operation=_error_test_operation(),
error_registry=TypeRegistry({}),
default_namespace="com.example",
wrapper_elements=("ErrorResponse", "Error"),
status=503,
context=TypedProperties(),
)
assert error.retry_after is None
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "breaking",
"description": "Updated retry quota costs and added an `is_long_polling` argument to `acquire_initial_retry_token`."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "feature",
"description": "Added support for separate backoff for throttling errors, the `x-amz-retry-after` header, per-service retry defaults, and long-polling backoff."
}
Loading
Loading