From 625fa86f58abca7301ee53730114fa9ce6b8c9a7 Mon Sep 17 00:00:00 2001 From: JordonPhillips Date: Wed, 16 Apr 2025 17:07:52 +0200 Subject: [PATCH 01/13] Update exception hierarchy and add retry info This updates exceptions to embed necessary retry info. This will allow any layer that has access to the exception to set or utilize it without having to have additional interfaces and hooks. This does not modify the retry strategy interface or implementation, that will come in a follow-up. --- .../python/codegen/ClientGenerator.java | 84 ++++++++++--------- .../smithy/python/codegen/CodegenUtils.java | 42 +--------- .../python/codegen/PythonSymbolProvider.java | 12 ++- .../generators/ServiceErrorGenerator.java | 38 ++------- .../codegen/generators/SetupGenerator.java | 25 +++--- .../generators/StructureGenerator.java | 21 ++--- .../HttpProtocolGeneratorUtils.java | 10 +-- .../writer/MarkdownToRstDocConverter.java | 8 +- .../codegen/reserved-error-member-names.txt | 4 + .../smithy-core/src/smithy_core/exceptions.py | 48 +++++++++++ 10 files changed, 143 insertions(+), 149 deletions(-) create mode 100644 codegen/core/src/main/resources/software/amazon/smithy/python/codegen/reserved-error-member-names.txt diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java index 8e8b4fb49..1c28e0f53 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java @@ -127,7 +127,6 @@ private void generateOperationExecutor(PythonWriter writer) { var transportRequest = context.applicationProtocol().requestType(); var transportResponse = context.applicationProtocol().responseType(); - var errorSymbol = CodegenUtils.getServiceError(context.settings()); var pluginSymbol = CodegenUtils.getPluginSymbol(context.settings()); var configSymbol = CodegenUtils.getConfigSymbol(context.settings()); @@ -302,18 +301,24 @@ def _classify_error( } writer.addStdlibImport("typing", "Any"); writer.addStdlibImport("asyncio", "iscoroutine"); + writer.addImports("smithy_core.exceptions", Set.of("SmithyException", "CallException")); + writer.pushState(); + writer.putContext("request", transportRequest); + writer.putContext("response", transportResponse); + writer.putContext("plugin", pluginSymbol); + writer.putContext("config", configSymbol); writer.write( """ async def _execute_operation[Input: SerializeableShape, Output: DeserializeableShape]( self, input: Input, - plugins: list[$1T], - serialize: Callable[[Input, $5T], Awaitable[$2T]], - deserialize: Callable[[$3T, $5T], Awaitable[Output]], - config: $5T, + plugins: list[${plugin:T}], + serialize: Callable[[Input, ${config:T}], Awaitable[${request:T}]], + deserialize: Callable[[${response:T}, ${config:T}], Awaitable[Output]], + config: ${config:T}, operation: APIOperation[Input, Output], - request_future: Future[RequestContext[Any, $2T]] | None = None, - response_future: Future[$3T] | None = None, + request_future: Future[RequestContext[Any, ${request:T}]] | None = None, + response_future: Future[${response:T}] | None = None, ) -> Output: try: return await self._handle_execution( @@ -321,27 +326,29 @@ def _classify_error( request_future, response_future, ) except Exception as e: + # Make sure every exception that we throw is an instance of SmithyException so + # customers can reliably catch everything we throw. + if not isinstance(e, SmithyException): + wrapped = CallException(str(e)) + wrapped.__cause__ = e + e = wrapped + if request_future is not None and not request_future.done(): - request_future.set_exception($4T(e)) + request_future.set_exception(e) if response_future is not None and not response_future.done(): - response_future.set_exception($4T(e)) - - # Make sure every exception that we throw is an instance of $4T so - # customers can reliably catch everything we throw. - if not isinstance(e, $4T): - raise $4T(e) from e + response_future.set_exception(e) raise async def _handle_execution[Input: SerializeableShape, Output: DeserializeableShape]( self, input: Input, - plugins: list[$1T], - serialize: Callable[[Input, $5T], Awaitable[$2T]], - deserialize: Callable[[$3T, $5T], Awaitable[Output]], - config: $5T, + plugins: list[${plugin:T}], + serialize: Callable[[Input, ${config:T}], Awaitable[${request:T}]], + deserialize: Callable[[${response:T}, ${config:T}], Awaitable[Output]], + config: ${config:T}, operation: APIOperation[Input, Output], - request_future: Future[RequestContext[Any, $2T]] | None, - response_future: Future[$3T] | None, + request_future: Future[RequestContext[Any, ${request:T}]] | None, + response_future: Future[${response:T}] | None, ) -> Output: operation_name = operation.schema.id.name logger.debug('Making request for operation "%s" with parameters: %s', operation_name, input) @@ -350,11 +357,16 @@ def _classify_error( plugin(config) input_context = InputContext(request=input, properties=TypedProperties({"config": config})) - transport_request: $2T | None = None - output_context: OutputContext[Input, Output, $2T | None, $3T | None] | None = None + transport_request: ${request:T} | None = None + output_context: OutputContext[ + Input, + Output, + ${request:T} | None, + ${response:T} | None + ] | None = None client_interceptors = cast( - list[Interceptor[Input, Output, $2T, $3T]], list(config.interceptors) + list[Interceptor[Input, Output, ${request:T}, ${response:T}]], list(config.interceptors) ) interceptor_chain = InterceptorChain(client_interceptors) @@ -455,24 +467,20 @@ await sleep(retry_token.retry_delay) async def _handle_attempt[Input: SerializeableShape, Output: DeserializeableShape]( self, - deserialize: Callable[[$3T, $5T], Awaitable[Output]], - interceptor: Interceptor[Input, Output, $2T, $3T], - context: RequestContext[Input, $2T], - config: $5T, + deserialize: Callable[[${response:T}, ${config:T}], Awaitable[Output]], + interceptor: Interceptor[Input, Output, ${request:T}, ${response:T}], + context: RequestContext[Input, ${request:T}], + config: ${config:T}, operation: APIOperation[Input, Output], - request_future: Future[RequestContext[Input, $2T]] | None, - ) -> OutputContext[Input, Output, $2T, $3T | None]: - transport_response: $3T | None = None + request_future: Future[RequestContext[Input, ${request:T}]] | None, + ) -> OutputContext[Input, Output, ${request:T}, ${response:T} | None]: + transport_response: ${response:T} | None = None try: # Step 7a: Invoke read_before_attempt interceptor.read_before_attempt(context) - """, - pluginSymbol, - transportRequest, - transportResponse, - errorSymbol, - configSymbol); + """); + writer.popState(); boolean supportsAuth = !ServiceIndex.of(model).getAuthSchemes(service).isEmpty(); writer.pushState(new ResolveIdentitySection()); @@ -873,8 +881,8 @@ private void writeSharedOperationInit(PythonWriter writer, OperationShape operat .orElse("The operation's input."); writer.write(""" - $L - """,docs); + $L + """, docs); writer.write(""); writer.write(":param input: $L", inputDocs); writer.write(""); diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/CodegenUtils.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/CodegenUtils.java index 734da83a5..47ed4d70d 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/CodegenUtils.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/CodegenUtils.java @@ -89,10 +89,10 @@ public static Symbol getPluginSymbol(PythonSettings settings) { /** * Gets the service error symbol. * - *

This error is the top-level error for the client. Every error surfaced by - * the client MUST be a subclass of this so that customers can reliably catch all - * exceptions it raises. The client implementation will wrap any errors that aren't - * already subclasses. + *

This error is the top-level error for the client. Errors surfaced by the client + * MUST be a subclass of this or SmithyException so that customers can reliably catch + * all the exceptions a client throws. The request pipeline will wrap exceptions of + * other types. * * @param settings The client settings, used to account for module configuration. * @return Returns the symbol for the client's error class. @@ -105,40 +105,6 @@ public static Symbol getServiceError(PythonSettings settings) { .build(); } - /** - * Gets the service API error symbol. - * - *

This error is the parent class for all errors returned over the wire by the - * service, including unknown errors. - * - * @param settings The client settings, used to account for module configuration. - * @return Returns the symbol for the client's API error class. - */ - public static Symbol getApiError(PythonSettings settings) { - return Symbol.builder() - .name("ApiError") - .namespace(String.format("%s.models", settings.moduleName()), ".") - .definitionFile(String.format("./src/%s/models.py", settings.moduleName())) - .build(); - } - - /** - * Gets the unknown API error symbol. - * - *

This error is the parent class for all errors returned over the wire by - * the service which aren't in the model. - * - * @param settings The client settings, used to account for module configuration. - * @return Returns the symbol for unknown API errors. - */ - public static Symbol getUnknownApiError(PythonSettings settings) { - return Symbol.builder() - .name("UnknownApiError") - .namespace(String.format("%s.models", settings.moduleName()), ".") - .definitionFile(String.format("./src/%s/models.py", settings.moduleName())) - .build(); - } - /** * Gets the symbol for the http auth params. * diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/PythonSymbolProvider.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/PythonSymbolProvider.java index 4c7907983..a44f7cb91 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/PythonSymbolProvider.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/PythonSymbolProvider.java @@ -9,7 +9,6 @@ import java.util.Locale; import java.util.logging.Logger; import software.amazon.smithy.codegen.core.ReservedWordSymbolProvider; -import software.amazon.smithy.codegen.core.ReservedWords; import software.amazon.smithy.codegen.core.ReservedWordsBuilder; import software.amazon.smithy.codegen.core.Symbol; import software.amazon.smithy.codegen.core.SymbolProvider; @@ -84,6 +83,10 @@ public PythonSymbolProvider(Model model, PythonSettings settings) { var reservedMemberNamesBuilder = new ReservedWordsBuilder() .loadWords(PythonSymbolProvider.class.getResource("reserved-member-names.txt"), this::escapeWord); + // Reserved words that only apply to error members. + var reservedErrorMembers = new ReservedWordsBuilder() + .loadWords(PythonSymbolProvider.class.getResource("reserved-error-member-names.txt"), this::escapeWord); + escaper = ReservedWordSymbolProvider.builder() .nameReservedWords(reservedClassNames) .memberReservedWords(reservedMemberNamesBuilder.build()) @@ -92,13 +95,8 @@ public PythonSymbolProvider(Model model, PythonSettings settings) { .escapePredicate((shape, symbol) -> !StringUtils.isEmpty(symbol.getDefinitionFile())) .buildEscaper(); - // Reserved words that only apply to error members. - ReservedWords reservedErrorMembers = reservedMemberNamesBuilder - .put("code", "code_") - .build(); - errorMemberEscaper = ReservedWordSymbolProvider.builder() - .memberReservedWords(reservedErrorMembers) + .memberReservedWords(reservedErrorMembers.build()) .escapePredicate((shape, symbol) -> !StringUtils.isEmpty(symbol.getDefinitionFile())) .buildEscaper(); } diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/ServiceErrorGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/ServiceErrorGenerator.java index 6bc84a761..8cc09d1d3 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/ServiceErrorGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/ServiceErrorGenerator.java @@ -4,7 +4,6 @@ */ package software.amazon.smithy.python.codegen.generators; -import java.util.Set; import software.amazon.smithy.codegen.core.WriterDelegator; import software.amazon.smithy.python.codegen.CodegenUtils; import software.amazon.smithy.python.codegen.PythonSettings; @@ -30,38 +29,15 @@ public void run() { var serviceError = CodegenUtils.getServiceError(settings); writers.useFileWriter(serviceError.getDefinitionFile(), serviceError.getNamespace(), writer -> { writer.addDependency(SmithyPythonDependency.SMITHY_CORE); - writer.addImport("smithy_core.exceptions", "SmithyException"); + writer.addImport("smithy_core.exceptions", "ModeledException"); writer.write(""" - class $L(SmithyException): - ""\"Base error for all errors in the service.""\" - pass - """, serviceError.getName()); - }); - - var apiError = CodegenUtils.getApiError(settings); - writers.useFileWriter(apiError.getDefinitionFile(), apiError.getNamespace(), writer -> { - writer.addStdlibImports("typing", Set.of("Literal", "ClassVar")); - var unknownApiError = CodegenUtils.getUnknownApiError(settings); - - writer.write(""" - @dataclass - class $1L($2T): - ""\"Base error for all API errors in the service.""\" - code: ClassVar[str] - fault: ClassVar[Literal["client", "server"]] + class $L(ModeledException): + ""\"Base error for all errors in the service. - message: str - - def __post_init__(self) -> None: - super().__init__(self.message) - - - @dataclass - class $3L($1L): - ""\"Error representing any unknown api errors.""\" - code: ClassVar[str] = 'Unknown' - fault: ClassVar[Literal["client", "server"]] = "client" - """, apiError.getName(), serviceError, unknownApiError.getName()); + Some exceptions do not extend from this class, including + synthetic, implicit, and shared exception types. + ""\" + """, serviceError.getName()); }); } } diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/SetupGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/SetupGenerator.java index fedc5b612..021ba4019 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/SetupGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/SetupGenerator.java @@ -451,7 +451,6 @@ private static void writeIndexes(GenerationContext context, String projectName) writeIndexFile(context, "docs/models/index.rst", "Models"); } - /** * Write the readme in the docs folder describing instructions for generation * @@ -461,18 +460,18 @@ private static void writeDocsReadme( GenerationContext context ) { context.writerDelegator().useFileWriter("docs/README.md", writer -> { - writer.write(""" - ## Generating Documentation - - Sphinx is used for documentation. You can generate HTML locally with the - following: - - ``` - $$ uv pip install ".[docs]" - $$ cd docs - $$ make html - ``` - """); + writer.write(""" + ## Generating Documentation + + Sphinx is used for documentation. You can generate HTML locally with the + following: + + ``` + $$ uv pip install ".[docs]" + $$ cd docs + $$ make html + ``` + """); }); } diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/StructureGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/StructureGenerator.java index e0e3d2dcd..726739cb1 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/StructureGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/StructureGenerator.java @@ -130,31 +130,26 @@ private void renderError() { writer.addStdlibImports("typing", Set.of("Literal", "ClassVar")); writer.addStdlibImport("dataclasses", "dataclass"); - // TODO: Implement protocol-level customization of the error code var fault = errorTrait.getValue(); - var code = shape.getId().getName(); var symbol = symbolProvider.toSymbol(shape); - var apiError = CodegenUtils.getApiError(settings); + var baseError = CodegenUtils.getServiceError(settings); writer.pushState(new ErrorSection(symbol)); writer.write(""" @dataclass(kw_only=True) class $1L($2T): - ${5C|} + ${4C|} + + fault: Literal["client", "server"] | None = $3S - code: ClassVar[str] = $3S - fault: ClassVar[Literal["client", "server"]] = $4S + ${5C|} - message: str ${6C|} ${7C|} - ${8C|} - """, symbol.getName(), - apiError, - code, + baseError, fault, writer.consumer(w -> writeClassDocs(true)), writer.consumer(w -> writeProperties()), @@ -325,7 +320,9 @@ private void writeMemberDocs(MemberShape member) { String memberName = symbolProvider.toMemberName(member); String docs = writer.formatDocs(String.format(":param %s: %s%s", - memberName, descriptionPrefix, trait.getValue())); + memberName, + descriptionPrefix, + trait.getValue())); writer.write(docs); }); } diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HttpProtocolGeneratorUtils.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HttpProtocolGeneratorUtils.java index b8841d26f..ccfc99097 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HttpProtocolGeneratorUtils.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HttpProtocolGeneratorUtils.java @@ -136,26 +136,24 @@ public static void generateErrorDispatcher( var transportResponse = context.applicationProtocol().responseType(); var delegator = context.writerDelegator(); var errorDispatcher = context.protocolGenerator().getErrorDeserializationFunction(context, operation); - var apiError = CodegenUtils.getApiError(context.settings()); - var unknownApiError = CodegenUtils.getUnknownApiError(context.settings()); + var apiError = CodegenUtils.getServiceError(context.settings()); var canReadResponseBody = canReadResponseBody(operation, context.model()); delegator.useFileWriter(errorDispatcher.getDefinitionFile(), errorDispatcher.getNamespace(), writer -> { writer.pushState(new ErrorDispatcherSection(operation, errorShapeToCode, errorMessageCodeGenerator)); writer.write(""" async def $1L(http_response: $2T, config: $3T) -> $4T: - ${6C|} + ${5C|} match code.lower(): - ${7C|} + ${6C|} case _: - return $5T(f"{code}: {message}") + return $4T(f"{code}: {message}") """, errorDispatcher.getName(), transportResponse, configSymbol, apiError, - unknownApiError, writer.consumer(w -> errorMessageCodeGenerator.accept(context, w, canReadResponseBody)), writer.consumer(w -> errorCases(context, w, operation, errorShapeToCode))); writer.popState(); diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/writer/MarkdownToRstDocConverter.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/writer/MarkdownToRstDocConverter.java index 52415c8d8..b2ab2173d 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/writer/MarkdownToRstDocConverter.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/writer/MarkdownToRstDocConverter.java @@ -77,7 +77,7 @@ public void head(Node node, int depth) { String text = textNode.text(); if (!text.trim().isEmpty()) { if (text.startsWith(":param ")) { - int secondColonIndex = text.indexOf(':', 1); + int secondColonIndex = text.indexOf(':', 1); writer.write(text.substring(0, secondColonIndex + 1)); //TODO right now the code generator gives us a mixture of // RST and HTML (for instance :param xyz:

docs @@ -85,7 +85,7 @@ public void head(Node node, int depth) { // starts a newline. We account for that with this if/else // statement, but we should refactor this in the future to // have a more elegant codepath. - if (secondColonIndex +1 == text.strip().length()) { + if (secondColonIndex + 1 == text.strip().length()) { writer.indent(); writer.ensureNewline(); } else { @@ -97,8 +97,8 @@ public void head(Node node, int depth) { } else { writer.writeInline(text); } - // Account for services making a paragraph tag that's empty except - // for a newline + // Account for services making a paragraph tag that's empty except + // for a newline } else if (node.parent() instanceof Element && ((Element) node.parent()).tagName().equals("p")) { writer.writeInline(text.replaceAll("[ \\t]+", "")); } diff --git a/codegen/core/src/main/resources/software/amazon/smithy/python/codegen/reserved-error-member-names.txt b/codegen/core/src/main/resources/software/amazon/smithy/python/codegen/reserved-error-member-names.txt new file mode 100644 index 000000000..e1b05dff9 --- /dev/null +++ b/codegen/core/src/main/resources/software/amazon/smithy/python/codegen/reserved-error-member-names.txt @@ -0,0 +1,4 @@ +is_retry_safe +retry_after +is_throttle +fault diff --git a/packages/smithy-core/src/smithy_core/exceptions.py b/packages/smithy-core/src/smithy_core/exceptions.py index 0c07e30d3..bf010e4c0 100644 --- a/packages/smithy-core/src/smithy_core/exceptions.py +++ b/packages/smithy-core/src/smithy_core/exceptions.py @@ -1,9 +1,57 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 +from dataclasses import dataclass, field +from typing import Literal, Protocol + + class SmithyException(Exception): """Base exception type for all exceptions raised by smithy-python.""" +@dataclass(kw_only=True) +class RetryInfo(Protocol): + is_retry_safe: bool | None = None + """Whether the exception is safe to retry. + + A value of True does not mean a retry will occur, but rather that a retry is allowed + to occur. + + A value of None indicates that there is not enough information available to + determine if a retry is safe. + """ + + retry_after: float | None = None + """The amount of time that should pass before a retry. + + Retry strategies MAY choose to wait longer. + """ + + is_throttle: bool = False + """Whether the error is a throttling error.""" + + +@dataclass(kw_only=True) +class CallException(SmithyException, RetryInfo): + """Base exceptio to be used in application-level errors.""" + + fault: Literal["client", "server"] | None = None + """Whether the client or server is at fault.""" + + message: str = field(default="", kw_only=False) + """The message of the error.""" + + def __post_init__(self): + super().__init__(self.message) + + +@dataclass(kw_only=True) +class ModeledException(CallException): + """Base excetpion to be used for modeled errors.""" + + fault: Literal["client", "server"] | None = "client" + """Whether the client or server is at fault.""" + + class SerializationException(Exception): """Base exception type for exceptions raised during serialization.""" From 4c4cdf80c598d7df43475c5a0ddbe1ed9a356675 Mon Sep 17 00:00:00 2001 From: JordonPhillips Date: Thu, 17 Apr 2025 18:05:12 +0200 Subject: [PATCH 02/13] Use CallException for unknown errors --- .../amazon/smithy/python/codegen/CodegenUtils.java | 5 +---- .../integrations/HttpProtocolGeneratorUtils.java | 14 ++++++++------ packages/smithy-core/src/smithy_core/exceptions.py | 12 +++++++++--- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/CodegenUtils.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/CodegenUtils.java index 47ed4d70d..689215ece 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/CodegenUtils.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/CodegenUtils.java @@ -89,10 +89,7 @@ public static Symbol getPluginSymbol(PythonSettings settings) { /** * Gets the service error symbol. * - *

This error is the top-level error for the client. Errors surfaced by the client - * MUST be a subclass of this or SmithyException so that customers can reliably catch - * all the exceptions a client throws. The request pipeline will wrap exceptions of - * other types. + *

This error is the top-level error for modeled client errors. * * @param settings The client settings, used to account for module configuration. * @return Returns the symbol for the client's error class. diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HttpProtocolGeneratorUtils.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HttpProtocolGeneratorUtils.java index ccfc99097..313dc3ce1 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HttpProtocolGeneratorUtils.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HttpProtocolGeneratorUtils.java @@ -136,24 +136,26 @@ public static void generateErrorDispatcher( var transportResponse = context.applicationProtocol().responseType(); var delegator = context.writerDelegator(); var errorDispatcher = context.protocolGenerator().getErrorDeserializationFunction(context, operation); - var apiError = CodegenUtils.getServiceError(context.settings()); var canReadResponseBody = canReadResponseBody(operation, context.model()); delegator.useFileWriter(errorDispatcher.getDefinitionFile(), errorDispatcher.getNamespace(), writer -> { writer.pushState(new ErrorDispatcherSection(operation, errorShapeToCode, errorMessageCodeGenerator)); + writer.addImport("smithy_core.exceptions", "CallException"); writer.write(""" - async def $1L(http_response: $2T, config: $3T) -> $4T: - ${5C|} + async def $1L(http_response: $2T, config: $3T) -> CallException: + ${4C|} match code.lower(): - ${6C|} + ${5C|} case _: - return $4T(f"{code}: {message}") + return CallException( + message=f"{code}: {message}", + fault="client" if http_response.status < 500 else "server" + ) """, errorDispatcher.getName(), transportResponse, configSymbol, - apiError, writer.consumer(w -> errorMessageCodeGenerator.accept(context, w, canReadResponseBody)), writer.consumer(w -> errorCases(context, w, operation, errorShapeToCode))); writer.popState(); diff --git a/packages/smithy-core/src/smithy_core/exceptions.py b/packages/smithy-core/src/smithy_core/exceptions.py index bf010e4c0..5e7eb325a 100644 --- a/packages/smithy-core/src/smithy_core/exceptions.py +++ b/packages/smithy-core/src/smithy_core/exceptions.py @@ -35,7 +35,10 @@ class CallException(SmithyException, RetryInfo): """Base exceptio to be used in application-level errors.""" fault: Literal["client", "server"] | None = None - """Whether the client or server is at fault.""" + """Whether the client or server is at fault. + + If None, then there was not enough information to determine fault. + """ message: str = field(default="", kw_only=False) """The message of the error.""" @@ -46,10 +49,13 @@ def __post_init__(self): @dataclass(kw_only=True) class ModeledException(CallException): - """Base excetpion to be used for modeled errors.""" + """Base exception to be used for modeled errors.""" fault: Literal["client", "server"] | None = "client" - """Whether the client or server is at fault.""" + """Whether the client or server is at fault. + + If None, then there was not enough information to determine fault. + """ class SerializationException(Exception): From a493125db9fe6c6d7ce7f5fc8b7e4856f91f02d1 Mon Sep 17 00:00:00 2001 From: JordonPhillips Date: Thu, 17 Apr 2025 19:54:52 +0200 Subject: [PATCH 03/13] Add design doc and a few generator tweaks --- .../generators/StructureGenerator.java | 15 +++ .../HttpProtocolGeneratorUtils.java | 6 +- designs/exceptions.md | 124 ++++++++++++++++++ .../smithy-core/src/smithy_core/exceptions.py | 20 +-- 4 files changed, 156 insertions(+), 9 deletions(-) create mode 100644 designs/exceptions.md diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/StructureGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/StructureGenerator.java index 726739cb1..410dcb0cc 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/StructureGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/StructureGenerator.java @@ -31,6 +31,7 @@ import software.amazon.smithy.model.traits.InputTrait; import software.amazon.smithy.model.traits.OutputTrait; import software.amazon.smithy.model.traits.RequiredTrait; +import software.amazon.smithy.model.traits.RetryableTrait; import software.amazon.smithy.model.traits.SensitiveTrait; import software.amazon.smithy.model.traits.StreamingTrait; import software.amazon.smithy.python.codegen.CodegenUtils; @@ -134,12 +135,26 @@ private void renderError() { var symbol = symbolProvider.toSymbol(shape); var baseError = CodegenUtils.getServiceError(settings); writer.pushState(new ErrorSection(symbol)); + writer.putContext("retryable", false); + writer.putContext("throttling", false); + + var retryableTrait = shape.getTrait(RetryableTrait.class); + if (retryableTrait.isPresent()) { + writer.putContext("retryable", true); + writer.putContext("throttling", retryableTrait.get().getThrottling()); + } writer.write(""" @dataclass(kw_only=True) class $1L($2T): ${4C|} fault: Literal["client", "server"] | None = $3S + ${?retryable} + is_retry_safe: bool | None = True + ${?throttling} + is_throttle: bool = True + ${/throttling} + ${/retryable} ${5C|} diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HttpProtocolGeneratorUtils.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HttpProtocolGeneratorUtils.java index 313dc3ce1..c8de9b77d 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HttpProtocolGeneratorUtils.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HttpProtocolGeneratorUtils.java @@ -140,6 +140,7 @@ public static void generateErrorDispatcher( delegator.useFileWriter(errorDispatcher.getDefinitionFile(), errorDispatcher.getNamespace(), writer -> { writer.pushState(new ErrorDispatcherSection(operation, errorShapeToCode, errorMessageCodeGenerator)); writer.addImport("smithy_core.exceptions", "CallException"); + // TODO: include standard retry-after in the pure-python version of this writer.write(""" async def $1L(http_response: $2T, config: $3T) -> CallException: ${4C|} @@ -148,9 +149,12 @@ public static void generateErrorDispatcher( ${5C|} case _: + is_throttle = http_response.status == 429 return CallException( message=f"{code}: {message}", - fault="client" if http_response.status < 500 else "server" + fault="client" if http_response.status < 500 else "server", + is_throttle=is_throttle, + is_retry_safe=is_throttle or None, ) """, errorDispatcher.getName(), diff --git a/designs/exceptions.md b/designs/exceptions.md new file mode 100644 index 000000000..97acbb3ff --- /dev/null +++ b/designs/exceptions.md @@ -0,0 +1,124 @@ +# Exceptions + +Exceptions are a necessary aspect of any software product (Go notwithstanding), +and care must be taken in how they're exposed. This document describes how +smithy-python clients will expose exceptions to customers. + +## Goals + +* Every exception raised by a Smithy client should be catchable with a single, + specific catch statement (that is, not just `except Exception`). +* Every modeled exception raised by a service should be catchable with a single, + specific catch statement. +* Exceptions should contain information about retryablility where relevant. + +## Specification + +Every exception raised by a Smithy client MUST inherit from `SmithyException`. + +```python +class SmithyException(Exception): + """Base exception type for all exceptions raised by smithy-python.""" +``` + +If an exception that is not a `SmithyException` is thrown while executing a +request, that exception MUST be wrapped in a `SmithyException` and the +`__cause__` MUST be set to the original exception. + +Just as in normal Python programming, different exception types SHOULD be made +for different kinds of exceptions. `SerializationException`, for example, will +serve as the exception type for any exceptions that occur while serializing a +request. + +### Retryability + +Not all exceptions need to include information about retryability, as most will +not be retryable at all. To avoid overly complicating the class hierarchy, +retryability properties will be standardized as a `Protocol` that exceptions MAY +implement. + +```python +@dataclass(kw_only=True) +@runtime_checkable +class RetryInfo(Protocol): + is_retry_safe: bool | None = None + """Whether the exception is safe to retry. + + A value of True does not mean a retry will occur, but rather that a retry is allowed + to occur. + + A value of None indicates that there is not enough information available to + determine if a retry is safe. + """ + + retry_after: float | None = None + """The amount of time that should pass before a retry. + + Retry strategies MAY choose to wait longer. + """ + + is_throttle: bool = False + """Whether the error is a throttling error.""" +``` + +If an exception with `RetryInfo` is received while attempting to send a +serialized request to the server, the contained information will be used to +inform the next retry. + +### Service Exceptions + +Exceptions returned by the service MUST be a `CallException`. `CallException`s +include a `fault` property that indicates whether the client or server is +responsible for the exception. HTTP protocols can determine this based on the +status code. + +Similarly, protocols can and should determine retry information. HTTP protocols +can generally be confident that a status code 429 is a throttling error and can +also make use of the `Retry-After` header. Specific protocols may also include +more information in protocol-specific headers. + +```python +type Fault = Literal["client", "server"] | None +"""Whether the client or server is at fault. + +If None, then there was not enough information to determine fault. +""" + + +@dataclass(kw_only=True) +class CallException(SmithyException, RetryInfo): + fault: Fault = None + message: str = field(default="", kw_only=False) +``` + +#### Modeled Exceptions + +Most exceptions thrown by a service will be present in the Smithy model for the +service. These exceptions will all be generated into the client package. Each +modeled exception will be inherit from a generated exception named +`ServiceException` which itself inherits from the static `ModeledException`. + +```python +@dataclass(kw_only=True) +class ModeledException(CallException): + """Base exception to be used for modeled errors.""" +``` + +The Smithy model itself can contain fault information in the +[error trait](https://smithy.io/2.0/spec/type-refinement-traits.html#smithy-api-error-trait) +and retry information in the +[retryable trait](https://smithy.io/2.0/spec/behavior-traits.html#retryable-trait). +This information will be statically generated onto the exception. + +```python +@dataclass(kw_only=True) +class ServiceException(ModeledException): + pass + + +@dataclass(kw_only=True) +class ThrottlingException(ServcieException): + fault: Fault = "client" + is_retry_safe: bool | None = True + is_throttle: bool = True +``` diff --git a/packages/smithy-core/src/smithy_core/exceptions.py b/packages/smithy-core/src/smithy_core/exceptions.py index 5e7eb325a..a0f194052 100644 --- a/packages/smithy-core/src/smithy_core/exceptions.py +++ b/packages/smithy-core/src/smithy_core/exceptions.py @@ -1,14 +1,22 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 from dataclasses import dataclass, field -from typing import Literal, Protocol +from typing import Literal, Protocol, runtime_checkable class SmithyException(Exception): """Base exception type for all exceptions raised by smithy-python.""" +type Fault = Literal["client", "server"] | None +"""Whether the client or server is at fault. + +If None, then there was not enough information to determine fault. +""" + + @dataclass(kw_only=True) +@runtime_checkable class RetryInfo(Protocol): is_retry_safe: bool | None = None """Whether the exception is safe to retry. @@ -32,9 +40,9 @@ class RetryInfo(Protocol): @dataclass(kw_only=True) class CallException(SmithyException, RetryInfo): - """Base exceptio to be used in application-level errors.""" + """Base exception to be used in application-level errors.""" - fault: Literal["client", "server"] | None = None + fault: Fault = None """Whether the client or server is at fault. If None, then there was not enough information to determine fault. @@ -51,11 +59,7 @@ def __post_init__(self): class ModeledException(CallException): """Base exception to be used for modeled errors.""" - fault: Literal["client", "server"] | None = "client" - """Whether the client or server is at fault. - - If None, then there was not enough information to determine fault. - """ + fault: Fault = "client" class SerializationException(Exception): From e2eaf9da1b6d5ff7dc7edabec1c38212d2b7ee4d Mon Sep 17 00:00:00 2001 From: JordonPhillips Date: Mon, 28 Apr 2025 18:01:14 +0200 Subject: [PATCH 04/13] Move error retry info to retries interfaces --- designs/exceptions.md | 2 +- .../smithy-core/src/smithy_core/exceptions.py | 33 +++++++++---------- .../src/smithy_core/interfaces/retries.py | 26 ++++++++++++++- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/designs/exceptions.md b/designs/exceptions.md index 97acbb3ff..83a15d78f 100644 --- a/designs/exceptions.md +++ b/designs/exceptions.md @@ -40,7 +40,7 @@ implement. ```python @dataclass(kw_only=True) @runtime_checkable -class RetryInfo(Protocol): +class ErrorRetryInfo(Protocol): is_retry_safe: bool | None = None """Whether the exception is safe to retry. diff --git a/packages/smithy-core/src/smithy_core/exceptions.py b/packages/smithy-core/src/smithy_core/exceptions.py index a0f194052..342bd2ff4 100644 --- a/packages/smithy-core/src/smithy_core/exceptions.py +++ b/packages/smithy-core/src/smithy_core/exceptions.py @@ -1,7 +1,7 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 from dataclasses import dataclass, field -from typing import Literal, Protocol, runtime_checkable +from typing import Literal class SmithyException(Exception): @@ -16,8 +16,21 @@ class SmithyException(Exception): @dataclass(kw_only=True) -@runtime_checkable -class RetryInfo(Protocol): +class CallException(SmithyException): + """Base exception to be used in application-level errors. + + Implements :py:class:`.interfaces.retries.ErrorRetryInfo`. + """ + + fault: Fault = None + """Whether the client or server is at fault. + + If None, then there was not enough information to determine fault. + """ + + message: str = field(default="", kw_only=False) + """The message of the error.""" + is_retry_safe: bool | None = None """Whether the exception is safe to retry. @@ -37,20 +50,6 @@ class RetryInfo(Protocol): is_throttle: bool = False """Whether the error is a throttling error.""" - -@dataclass(kw_only=True) -class CallException(SmithyException, RetryInfo): - """Base exception to be used in application-level errors.""" - - fault: Fault = None - """Whether the client or server is at fault. - - If None, then there was not enough information to determine fault. - """ - - message: str = field(default="", kw_only=False) - """The message of the error.""" - def __post_init__(self): super().__init__(self.message) diff --git a/packages/smithy-core/src/smithy_core/interfaces/retries.py b/packages/smithy-core/src/smithy_core/interfaces/retries.py index e0af8a3cf..dfd0ae64d 100644 --- a/packages/smithy-core/src/smithy_core/interfaces/retries.py +++ b/packages/smithy-core/src/smithy_core/interfaces/retries.py @@ -2,7 +2,31 @@ # SPDX-License-Identifier: Apache-2.0 from dataclasses import dataclass from enum import Enum -from typing import Protocol +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class ErrorRetryInfo(Protocol): + """A protocol for exceptions that have retry information embedded.""" + + is_retry_safe: bool | None = None + """Whether the exception is safe to retry. + + A value of True does not mean a retry will occur, but rather that a retry is allowed + to occur. + + A value of None indicates that there is not enough information available to + determine if a retry is safe. + """ + + retry_after: float | None = None + """The amount of time that should pass before a retry. + + Retry strategies MAY choose to wait longer. + """ + + is_throttle: bool = False + """Whether the error is a throttling error.""" class RetryErrorType(Enum): From bcc95b1daec57de39b23eeeadc2f19863f8527ad Mon Sep 17 00:00:00 2001 From: JordonPhillips Date: Mon, 28 Apr 2025 18:19:31 +0200 Subject: [PATCH 05/13] Update HasFault --- .../smithy-core/src/smithy_core/interfaces/exceptions.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/smithy-core/src/smithy_core/interfaces/exceptions.py b/packages/smithy-core/src/smithy_core/interfaces/exceptions.py index 240a7469c..3a108e7b6 100644 --- a/packages/smithy-core/src/smithy_core/interfaces/exceptions.py +++ b/packages/smithy-core/src/smithy_core/interfaces/exceptions.py @@ -1,6 +1,8 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -from typing import ClassVar, Literal, Protocol, runtime_checkable +from typing import Protocol, runtime_checkable + +from ..exceptions import Fault @runtime_checkable @@ -10,4 +12,4 @@ class HasFault(Protocol): All modeled errors will have a fault that is either "client" or "server". """ - fault: ClassVar[Literal["client", "server"]] + fault: Fault | None From 4028c844f1eb1e8bab03b2d5a6e50540f68eb83e Mon Sep 17 00:00:00 2001 From: JordonPhillips Date: Mon, 28 Apr 2025 17:55:49 +0200 Subject: [PATCH 06/13] Refactor retry interfaces to use exception-based information --- .../src/smithy_core/interfaces/retries.py | 45 +------------------ .../smithy-core/src/smithy_core/retries.py | 13 ++---- .../smithy-core/tests/unit/test_retries.py | 40 ++++++++++++----- 3 files changed, 34 insertions(+), 64 deletions(-) diff --git a/packages/smithy-core/src/smithy_core/interfaces/retries.py b/packages/smithy-core/src/smithy_core/interfaces/retries.py index dfd0ae64d..1e0f00556 100644 --- a/packages/smithy-core/src/smithy_core/interfaces/retries.py +++ b/packages/smithy-core/src/smithy_core/interfaces/retries.py @@ -1,7 +1,6 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 from dataclasses import dataclass -from enum import Enum from typing import Protocol, runtime_checkable @@ -29,43 +28,6 @@ class ErrorRetryInfo(Protocol): """Whether the error is a throttling error.""" -class RetryErrorType(Enum): - """Classification of errors based on desired retry behavior.""" - - TRANSIENT = 1 - """A connection level error such as a socket timeout, socket connect error, TLS - negotiation timeout.""" - - THROTTLING = 2 - """The server explicitly told the client to back off, for example with HTTP status - 429 or 503.""" - - SERVER_ERROR = 3 - """A server error that should be retried and does not match the definition of - ``THROTTLING``.""" - - CLIENT_ERROR = 4 - """Doesn't count against any budgets. - - This could be something like a 401 challenge in HTTP. - """ - - -@dataclass(kw_only=True) -class RetryErrorInfo: - """Container for information about a retryable error.""" - - error_type: RetryErrorType - """Classification of error based on desired retry behavior.""" - - retry_after_hint: float | None = None - """Protocol hint for computing the timespan to delay before the next retry. - - This could come from HTTP's 'retry-after' header or similar mechanisms in other - protocols. - """ - - class RetryBackoffStrategy(Protocol): """Stateless strategy for computing retry delays based on retry attempt account.""" @@ -113,7 +75,7 @@ def acquire_initial_retry_token( ... def refresh_retry_token_for_retry( - self, *, token_to_renew: RetryToken, error_info: RetryErrorInfo + self, *, token_to_renew: RetryToken, error: Exception ) -> RetryToken: """Replace an existing retry token from a failed attempt with a new token. @@ -124,10 +86,7 @@ def refresh_retry_token_for_retry( the retry attempt and raise the error as exception. :param token_to_renew: The token used for the previous failed attempt. - - :param error_info: If no further retry is allowed, this information is used to - construct the exception. - + :param error: The error that triggered the need for a retry. :raises SmithyRetryException: If no further retry attempts are allowed. """ ... diff --git a/packages/smithy-core/src/smithy_core/retries.py b/packages/smithy-core/src/smithy_core/retries.py index 32275bc38..117ba604c 100644 --- a/packages/smithy-core/src/smithy_core/retries.py +++ b/packages/smithy-core/src/smithy_core/retries.py @@ -5,8 +5,6 @@ from dataclasses import dataclass from enum import Enum -from smithy_core.interfaces.retries import RetryErrorType - from .exceptions import SmithyRetryException from .interfaces import retries as retries_interface @@ -220,7 +218,7 @@ def refresh_retry_token_for_retry( self, *, token_to_renew: retries_interface.RetryToken, - error_info: retries_interface.RetryErrorInfo, + error: Exception, ) -> SimpleRetryToken: """Replace an existing retry token from a failed attempt with a new token. @@ -228,13 +226,10 @@ def refresh_retry_token_for_retry( the new token exceeds the ``max_attempts`` value. :param token_to_renew: The token used for the previous failed attempt. - - :param error_info: If no further retry is allowed, this information is used to - construct the exception. - + :param error: The error that triggered the need for a retry. :raises SmithyRetryException: If no further retry attempts are allowed. """ - if error_info.error_type is not RetryErrorType.CLIENT_ERROR: + if isinstance(error, retries_interface.ErrorRetryInfo) and error.is_retry_safe: retry_count = token_to_renew.retry_count + 1 if retry_count >= self.max_attempts: raise SmithyRetryException( @@ -243,7 +238,7 @@ def refresh_retry_token_for_retry( retry_delay = self.backoff_strategy.compute_next_backoff_delay(retry_count) return SimpleRetryToken(retry_count=retry_count, retry_delay=retry_delay) else: - raise SmithyRetryException(f"Error is not retryable: {error_info}") + raise SmithyRetryException(f"Error is not retryable: {error}") def record_success(self, *, token: retries_interface.RetryToken) -> None: """Not used by this retry strategy.""" diff --git a/packages/smithy-core/tests/unit/test_retries.py b/packages/smithy-core/tests/unit/test_retries.py index 99acb877b..3703d4ec1 100644 --- a/packages/smithy-core/tests/unit/test_retries.py +++ b/packages/smithy-core/tests/unit/test_retries.py @@ -2,8 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import pytest -from smithy_core.exceptions import SmithyRetryException -from smithy_core.interfaces.retries import RetryErrorInfo, RetryErrorType +from smithy_core.exceptions import CallException, SmithyRetryException from smithy_core.retries import ExponentialBackoffJitterType as EBJT from smithy_core.retries import ExponentialRetryBackoffStrategy, SimpleRetryStrategy @@ -61,26 +60,43 @@ def test_simple_retry_strategy(max_attempts: int) -> None: backoff_strategy=ExponentialRetryBackoffStrategy(backoff_scale_value=5), max_attempts=max_attempts, ) - error_info = RetryErrorInfo(error_type=RetryErrorType.THROTTLING) + error = CallException(is_retry_safe=True) token = strategy.acquire_initial_retry_token() for _ in range(max_attempts - 1): token = strategy.refresh_retry_token_for_retry( - token_to_renew=token, error_info=error_info + token_to_renew=token, error=error ) with pytest.raises(SmithyRetryException): - strategy.refresh_retry_token_for_retry( - token_to_renew=token, error_info=error_info - ) + strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) -def test_simple_retry_strategy_does_not_retry_client_errors() -> None: +def test_simple_retry_does_not_retry_unclassified() -> None: strategy = SimpleRetryStrategy( backoff_strategy=ExponentialRetryBackoffStrategy(backoff_scale_value=5), max_attempts=2, ) - error_info = RetryErrorInfo(error_type=RetryErrorType.CLIENT_ERROR) token = strategy.acquire_initial_retry_token() with pytest.raises(SmithyRetryException): - strategy.refresh_retry_token_for_retry( - token_to_renew=token, error_info=error_info - ) + strategy.refresh_retry_token_for_retry(token_to_renew=token, error=Exception()) + + +def test_simple_retry_does_not_retry_when_safety_unknown() -> None: + strategy = SimpleRetryStrategy( + backoff_strategy=ExponentialRetryBackoffStrategy(backoff_scale_value=5), + max_attempts=2, + ) + error = CallException(is_retry_safe=None) + token = strategy.acquire_initial_retry_token() + with pytest.raises(SmithyRetryException): + strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) + + +def test_simple_retry_does_not_retry_unsafe() -> None: + strategy = SimpleRetryStrategy( + backoff_strategy=ExponentialRetryBackoffStrategy(backoff_scale_value=5), + max_attempts=2, + ) + error = CallException(fault="client", is_retry_safe=False) + token = strategy.acquire_initial_retry_token() + with pytest.raises(SmithyRetryException): + strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) From b4afd798a26cd8ad6adc1c75fb249027570f1115 Mon Sep 17 00:00:00 2001 From: JordonPhillips Date: Mon, 28 Apr 2025 18:23:50 +0200 Subject: [PATCH 07/13] Remove manual error classification --- .../python/codegen/ClientGenerator.java | 47 +------------------ 1 file changed, 1 insertion(+), 46 deletions(-) diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java index 1c28e0f53..258eabd08 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java @@ -148,55 +148,13 @@ private void generateOperationExecutor(PythonWriter writer) { "OutputContext", "RequestContext", "ResponseContext")); - writer.addImports("smithy_core.interfaces.retries", Set.of("RetryErrorInfo", "RetryErrorType")); writer.addImport("smithy_core.interfaces.exceptions", "HasFault"); writer.addImport("smithy_core.types", "TypedProperties"); writer.addImport("smithy_core.serializers", "SerializeableShape"); writer.addImport("smithy_core.deserializers", "DeserializeableShape"); writer.addImport("smithy_core.schemas", "APIOperation"); - - writer.indent(); - writer.write(""" - def _classify_error( - self, - *, - error: Exception, - context: ResponseContext[Any, $1T, $2T | None] - ) -> RetryErrorInfo: - logger.debug("Classifying error: %s", error) - """, transportRequest, transportResponse); writer.indent(); - if (context.applicationProtocol().isHttpProtocol()) { - writer.addDependency(SmithyPythonDependency.SMITHY_HTTP); - writer.write(""" - if not isinstance(error, HasFault) and not context.transport_response: - return RetryErrorInfo(error_type=RetryErrorType.TRANSIENT) - - if context.transport_response: - if context.transport_response.status in [429, 503]: - retry_after = None - retry_header = context.transport_response.fields["retry-after"] - if retry_header and retry_header.values: - retry_after = float(retry_header.values[0]) - return RetryErrorInfo(error_type=RetryErrorType.THROTTLING, retry_after_hint=retry_after) - - if context.transport_response.status >= 500: - return RetryErrorInfo(error_type=RetryErrorType.SERVER_ERROR) - - """); - } - - writer.write(""" - error_type = RetryErrorType.CLIENT_ERROR - if isinstance(error, HasFault) and error.fault == "server": - error_type = RetryErrorType.SERVER_ERROR - - return RetryErrorInfo(error_type=error_type) - - """); - writer.dedent(); - if (hasStreaming) { writer.addStdlibImports("typing", Set.of("Any", "Awaitable")); writer.addStdlibImport("asyncio"); @@ -425,10 +383,7 @@ def _classify_error( try: retry_token = retry_strategy.refresh_retry_token_for_retry( token_to_renew=retry_token, - error_info=self._classify_error( - error=output_context.response, - context=output_context, - ) + error=output_context.response, ) except SmithyRetryException: raise output_context.response From 106b79a6ce4d88c23b3520597a5677bc07310615 Mon Sep 17 00:00:00 2001 From: JordonPhillips Date: Tue, 29 Apr 2025 13:47:54 +0200 Subject: [PATCH 08/13] Remove extraneous None from HasFault --- packages/smithy-core/src/smithy_core/interfaces/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/smithy-core/src/smithy_core/interfaces/exceptions.py b/packages/smithy-core/src/smithy_core/interfaces/exceptions.py index 3a108e7b6..a299640f0 100644 --- a/packages/smithy-core/src/smithy_core/interfaces/exceptions.py +++ b/packages/smithy-core/src/smithy_core/interfaces/exceptions.py @@ -12,4 +12,4 @@ class HasFault(Protocol): All modeled errors will have a fault that is either "client" or "server". """ - fault: Fault | None + fault: Fault From 45e0bf40790194dc40254c68d1d60c257dcb78b3 Mon Sep 17 00:00:00 2001 From: JordonPhillips Date: Tue, 29 Apr 2025 13:50:26 +0200 Subject: [PATCH 09/13] Rename is_throttle to is_throttling_error --- .../smithy/python/codegen/generators/StructureGenerator.java | 2 +- .../codegen/integrations/HttpProtocolGeneratorUtils.java | 2 +- .../smithy/python/codegen/reserved-error-member-names.txt | 2 +- designs/exceptions.md | 4 ++-- packages/smithy-core/src/smithy_core/exceptions.py | 2 +- packages/smithy-core/src/smithy_core/interfaces/retries.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/StructureGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/StructureGenerator.java index 410dcb0cc..9a1029011 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/StructureGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/StructureGenerator.java @@ -152,7 +152,7 @@ class $1L($2T): ${?retryable} is_retry_safe: bool | None = True ${?throttling} - is_throttle: bool = True + is_throttling_error: bool = True ${/throttling} ${/retryable} diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HttpProtocolGeneratorUtils.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HttpProtocolGeneratorUtils.java index c8de9b77d..007a355aa 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HttpProtocolGeneratorUtils.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HttpProtocolGeneratorUtils.java @@ -153,7 +153,7 @@ public static void generateErrorDispatcher( return CallException( message=f"{code}: {message}", fault="client" if http_response.status < 500 else "server", - is_throttle=is_throttle, + is_throttling_error=is_throttle, is_retry_safe=is_throttle or None, ) """, diff --git a/codegen/core/src/main/resources/software/amazon/smithy/python/codegen/reserved-error-member-names.txt b/codegen/core/src/main/resources/software/amazon/smithy/python/codegen/reserved-error-member-names.txt index e1b05dff9..37813258a 100644 --- a/codegen/core/src/main/resources/software/amazon/smithy/python/codegen/reserved-error-member-names.txt +++ b/codegen/core/src/main/resources/software/amazon/smithy/python/codegen/reserved-error-member-names.txt @@ -1,4 +1,4 @@ is_retry_safe retry_after -is_throttle +is_throttling_error fault diff --git a/designs/exceptions.md b/designs/exceptions.md index 83a15d78f..f043eef6d 100644 --- a/designs/exceptions.md +++ b/designs/exceptions.md @@ -57,7 +57,7 @@ class ErrorRetryInfo(Protocol): Retry strategies MAY choose to wait longer. """ - is_throttle: bool = False + is_throttling_error: bool = False """Whether the error is a throttling error.""" ``` @@ -120,5 +120,5 @@ class ServiceException(ModeledException): class ThrottlingException(ServcieException): fault: Fault = "client" is_retry_safe: bool | None = True - is_throttle: bool = True + is_throttling_error: bool = True ``` diff --git a/packages/smithy-core/src/smithy_core/exceptions.py b/packages/smithy-core/src/smithy_core/exceptions.py index 342bd2ff4..13741c241 100644 --- a/packages/smithy-core/src/smithy_core/exceptions.py +++ b/packages/smithy-core/src/smithy_core/exceptions.py @@ -47,7 +47,7 @@ class CallException(SmithyException): Retry strategies MAY choose to wait longer. """ - is_throttle: bool = False + is_throttling_error: bool = False """Whether the error is a throttling error.""" def __post_init__(self): diff --git a/packages/smithy-core/src/smithy_core/interfaces/retries.py b/packages/smithy-core/src/smithy_core/interfaces/retries.py index 1e0f00556..cb3b66f27 100644 --- a/packages/smithy-core/src/smithy_core/interfaces/retries.py +++ b/packages/smithy-core/src/smithy_core/interfaces/retries.py @@ -24,7 +24,7 @@ class ErrorRetryInfo(Protocol): Retry strategies MAY choose to wait longer. """ - is_throttle: bool = False + is_throttling_error: bool = False """Whether the error is a throttling error.""" From 2adda66b480f2470fdb983785cf78e415997b236 Mon Sep 17 00:00:00 2001 From: JordonPhillips Date: Tue, 29 Apr 2025 14:03:32 +0200 Subject: [PATCH 10/13] Replace Exception suffix with Error PEP8 prefers that we have "Error" as the suffix for errors, which makes sense in a world where exceptions are regularly used for control flow. --- .../python/codegen/ClientGenerator.java | 11 +++-- .../generators/ServiceErrorGenerator.java | 4 +- .../codegen/generators/UnionGenerator.java | 12 +++--- .../HttpProtocolGeneratorUtils.java | 6 +-- designs/exceptions.md | 42 +++++++++---------- .../src/smithy_aws_core/auth/sigv4.py | 4 +- .../credentials_resolvers/environment.py | 4 +- .../credentials_resolvers/imds.py | 6 +-- .../test_environment_credentials_resolver.py | 10 ++--- .../smithy_aws_event_stream/aio/__init__.py | 4 +- .../src/smithy_aws_event_stream/exceptions.py | 4 +- .../tests/unit/_private/__init__.py | 22 +++++----- .../smithy-core/src/smithy_core/__init__.py | 4 +- .../smithy_core/aio/interfaces/__init__.py | 6 +-- .../smithy-core/src/smithy_core/aio/types.py | 8 ++-- .../smithy-core/src/smithy_core/aio/utils.py | 6 +-- .../src/smithy_core/deserializers.py | 6 +-- .../smithy-core/src/smithy_core/documents.py | 16 +++---- .../smithy-core/src/smithy_core/exceptions.py | 22 +++++----- .../src/smithy_core/interfaces/retries.py | 10 ++--- .../smithy-core/src/smithy_core/retries.py | 8 ++-- .../smithy-core/src/smithy_core/schemas.py | 16 +++---- .../src/smithy_core/serializers.py | 6 +-- .../smithy-core/src/smithy_core/shapes.py | 10 ++--- packages/smithy-core/src/smithy_core/types.py | 4 +- packages/smithy-core/src/smithy_core/utils.py | 21 ++++------ .../smithy-core/tests/unit/aio/test_types.py | 8 ++-- .../smithy-core/tests/unit/test_documents.py | 32 +++++++------- .../smithy-core/tests/unit/test_retries.py | 16 +++---- .../smithy-core/tests/unit/test_schemas.py | 12 +++--- .../smithy-core/tests/unit/test_shapes.py | 6 +-- packages/smithy-core/tests/unit/test_types.py | 4 +- packages/smithy-core/tests/unit/test_uri.py | 4 +- packages/smithy-core/tests/unit/test_utils.py | 10 ++--- .../src/smithy_http/aio/aiohttp.py | 4 +- .../src/smithy_http/aio/auth/apikey.py | 4 +- .../smithy-http/src/smithy_http/aio/crt.py | 14 +++---- .../src/smithy_http/aio/protocols.py | 4 +- .../src/smithy_http/deserializers.py | 6 +-- .../smithy-http/src/smithy_http/exceptions.py | 4 +- packages/smithy-http/src/smithy_http/utils.py | 6 +-- .../tests/unit/aio/auth/test_apikey.py | 4 +- packages/smithy-http/tests/unit/test_utils.py | 4 +- .../src/smithy_json/_private/deserializers.py | 4 +- 44 files changed, 203 insertions(+), 215 deletions(-) diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java index 258eabd08..b30e3f2a7 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java @@ -140,7 +140,6 @@ private void generateOperationExecutor(PythonWriter writer) { writer.addStdlibImport("dataclasses", "replace"); writer.addDependency(SmithyPythonDependency.SMITHY_CORE); - writer.addImport("smithy_core.exceptions", "SmithyRetryException"); writer.addImports("smithy_core.interceptors", Set.of("Interceptor", "InterceptorChain", @@ -259,7 +258,7 @@ private void generateOperationExecutor(PythonWriter writer) { } writer.addStdlibImport("typing", "Any"); writer.addStdlibImport("asyncio", "iscoroutine"); - writer.addImports("smithy_core.exceptions", Set.of("SmithyException", "CallException")); + writer.addImports("smithy_core.exceptions", Set.of("SmithyError", "CallError", "RetryError")); writer.pushState(); writer.putContext("request", transportRequest); writer.putContext("response", transportResponse); @@ -284,10 +283,10 @@ private void generateOperationExecutor(PythonWriter writer) { request_future, response_future, ) except Exception as e: - # Make sure every exception that we throw is an instance of SmithyException so + # Make sure every exception that we throw is an instance of SmithyError so # customers can reliably catch everything we throw. - if not isinstance(e, SmithyException): - wrapped = CallException(str(e)) + if not isinstance(e, SmithyError): + wrapped = CallError(str(e)) wrapped.__cause__ = e e = wrapped @@ -385,7 +384,7 @@ private void generateOperationExecutor(PythonWriter writer) { token_to_renew=retry_token, error=output_context.response, ) - except SmithyRetryException: + except RetryError: raise output_context.response logger.debug( "Retry needed. Attempting request #%s in %.4f seconds.", diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/ServiceErrorGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/ServiceErrorGenerator.java index 8cc09d1d3..3b6a062ae 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/ServiceErrorGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/ServiceErrorGenerator.java @@ -29,9 +29,9 @@ public void run() { var serviceError = CodegenUtils.getServiceError(settings); writers.useFileWriter(serviceError.getDefinitionFile(), serviceError.getNamespace(), writer -> { writer.addDependency(SmithyPythonDependency.SMITHY_CORE); - writer.addImport("smithy_core.exceptions", "ModeledException"); + writer.addImport("smithy_core.exceptions", "ModeledError"); writer.write(""" - class $L(ModeledException): + class $L(ModeledError): ""\"Base error for all errors in the service. Some exceptions do not extend from this class, including diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/UnionGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/UnionGenerator.java index b05c56fbb..c24c6590f 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/UnionGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/UnionGenerator.java @@ -104,7 +104,7 @@ def deserialize(cls, deserializer: ShapeDeserializer) -> Self: // realistic implementation. var unknownSymbol = symbolProvider.toSymbol(shape).expectProperty(SymbolProperties.UNION_UNKNOWN); writer.pushState(new UnionMemberSection(unknownSymbol)); - writer.addImport("smithy_core.exceptions", "SmithyException"); + writer.addImport("smithy_core.exceptions", "SerializationError"); writer.write(""" @dataclass class $1L: @@ -119,10 +119,10 @@ class $1L: tag: str def serialize(self, serializer: ShapeSerializer): - raise SmithyException("Unknown union variants may not be serialized.") + raise SerializationError("Unknown union variants may not be serialized.") def serialize_members(self, serializer: ShapeSerializer): - raise SmithyException("Unknown union variants may not be serialized.") + raise SerializationError("Unknown union variants may not be serialized.") @classmethod def deserialize(cls, deserializer: ShapeDeserializer) -> Self: @@ -147,7 +147,7 @@ private void generateDeserializer() { writer.addLogger(); writer.addStdlibImports("typing", Set.of("Self", "Any")); writer.addImport("smithy_core.deserializers", "ShapeDeserializer"); - writer.addImport("smithy_core.exceptions", "SmithyException"); + writer.addImport("smithy_core.exceptions", "SerializationError"); // TODO: add in unknown handling @@ -164,7 +164,7 @@ def deserialize(self, deserializer: ShapeDeserializer) -> $2T: deserializer.read_struct($3T, self._consumer) if self._result is None: - raise SmithyException("Unions must have exactly one value, but found none.") + raise SerializationError("Unions must have exactly one value, but found none.") return self._result @@ -176,7 +176,7 @@ def _consumer(self, schema: Schema, de: ShapeDeserializer) -> None: def _set_result(self, value: $2T) -> None: if self._result is not None: - raise SmithyException("Unions must have exactly one value, but found more than one.") + raise SerializationError("Unions must have exactly one value, but found more than one.") self._result = value """, deserializerSymbol.getName(), diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HttpProtocolGeneratorUtils.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HttpProtocolGeneratorUtils.java index 007a355aa..1f45ebe06 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HttpProtocolGeneratorUtils.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/HttpProtocolGeneratorUtils.java @@ -139,10 +139,10 @@ public static void generateErrorDispatcher( var canReadResponseBody = canReadResponseBody(operation, context.model()); delegator.useFileWriter(errorDispatcher.getDefinitionFile(), errorDispatcher.getNamespace(), writer -> { writer.pushState(new ErrorDispatcherSection(operation, errorShapeToCode, errorMessageCodeGenerator)); - writer.addImport("smithy_core.exceptions", "CallException"); + writer.addImport("smithy_core.exceptions", "CallError"); // TODO: include standard retry-after in the pure-python version of this writer.write(""" - async def $1L(http_response: $2T, config: $3T) -> CallException: + async def $1L(http_response: $2T, config: $3T) -> CallError: ${4C|} match code.lower(): @@ -150,7 +150,7 @@ public static void generateErrorDispatcher( case _: is_throttle = http_response.status == 429 - return CallException( + return CallError( message=f"{code}: {message}", fault="client" if http_response.status < 500 else "server", is_throttling_error=is_throttle, diff --git a/designs/exceptions.md b/designs/exceptions.md index f043eef6d..059060877 100644 --- a/designs/exceptions.md +++ b/designs/exceptions.md @@ -14,21 +14,20 @@ smithy-python clients will expose exceptions to customers. ## Specification -Every exception raised by a Smithy client MUST inherit from `SmithyException`. +Every exception raised by a Smithy client MUST inherit from `SmithyError`. ```python -class SmithyException(Exception): +class SmithyError(Exception): """Base exception type for all exceptions raised by smithy-python.""" ``` -If an exception that is not a `SmithyException` is thrown while executing a -request, that exception MUST be wrapped in a `SmithyException` and the -`__cause__` MUST be set to the original exception. +If an exception that is not a `SmithyError` is thrown while executing a request, +that exception MUST be wrapped in a `SmithyError` and the `__cause__` MUST be +set to the original exception. Just as in normal Python programming, different exception types SHOULD be made -for different kinds of exceptions. `SerializationException`, for example, will -serve as the exception type for any exceptions that occur while serializing a -request. +for different kinds of exceptions. `SerializationError`, for example, will serve +as the exception type for any exceptions that occur while serializing a request. ### Retryability @@ -42,7 +41,7 @@ implement. @runtime_checkable class ErrorRetryInfo(Protocol): is_retry_safe: bool | None = None - """Whether the exception is safe to retry. + """Whether the error is safe to retry. A value of True does not mean a retry will occur, but rather that a retry is allowed to occur. @@ -61,16 +60,15 @@ class ErrorRetryInfo(Protocol): """Whether the error is a throttling error.""" ``` -If an exception with `RetryInfo` is received while attempting to send a +If an exception with `ErrorRetryInfo` is received while attempting to send a serialized request to the server, the contained information will be used to inform the next retry. -### Service Exceptions +### Service Errors -Exceptions returned by the service MUST be a `CallException`. `CallException`s -include a `fault` property that indicates whether the client or server is -responsible for the exception. HTTP protocols can determine this based on the -status code. +Errors returned by the service MUST be a `CallError`. `CallError`s include a +`fault` property that indicates whether the client or server is responsible for +the exception. HTTP protocols can determine this based on the status code. Similarly, protocols can and should determine retry information. HTTP protocols can generally be confident that a status code 429 is a throttling error and can @@ -86,21 +84,21 @@ If None, then there was not enough information to determine fault. @dataclass(kw_only=True) -class CallException(SmithyException, RetryInfo): +class CallError(SmithyError, ErrorRetryInfo): fault: Fault = None message: str = field(default="", kw_only=False) ``` -#### Modeled Exceptions +#### Modeled Errors Most exceptions thrown by a service will be present in the Smithy model for the service. These exceptions will all be generated into the client package. Each modeled exception will be inherit from a generated exception named -`ServiceException` which itself inherits from the static `ModeledException`. +`ServiceError` which itself inherits from the static `ModeledError`. ```python @dataclass(kw_only=True) -class ModeledException(CallException): +class ModeledError(CallError): """Base exception to be used for modeled errors.""" ``` @@ -112,13 +110,13 @@ This information will be statically generated onto the exception. ```python @dataclass(kw_only=True) -class ServiceException(ModeledException): +class ServiceError(ModeledError): pass @dataclass(kw_only=True) -class ThrottlingException(ServcieException): - fault: Fault = "client" +class ThrottlingError(ServiceError): + fault: Fault = "server" is_retry_safe: bool | None = True is_throttling_error: bool = True ``` diff --git a/packages/smithy-aws-core/src/smithy_aws_core/auth/sigv4.py b/packages/smithy-aws-core/src/smithy_aws_core/auth/sigv4.py index 2cdd2245f..c206974cb 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/auth/sigv4.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/auth/sigv4.py @@ -5,7 +5,7 @@ from aws_sdk_signers import AsyncSigV4Signer, SigV4SigningProperties from smithy_core.aio.interfaces.identity import IdentityResolver -from smithy_core.exceptions import SmithyIdentityException +from smithy_core.exceptions import SmithyIdentityError from smithy_core.interfaces.identity import IdentityProperties from smithy_http.aio.interfaces.auth import HTTPAuthScheme, HTTPSigner @@ -47,7 +47,7 @@ def identity_resolver( self, *, config: SigV4Config ) -> IdentityResolver[AWSCredentialsIdentity, IdentityProperties]: if not config.aws_credentials_identity_resolver: - raise SmithyIdentityException( + raise SmithyIdentityError( "Attempted to use SigV4 auth, but aws_credentials_identity_resolver was not " "set on the config." ) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/credentials_resolvers/environment.py b/packages/smithy-aws-core/src/smithy_aws_core/credentials_resolvers/environment.py index 34cea57a4..08aa5fc36 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/credentials_resolvers/environment.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/credentials_resolvers/environment.py @@ -3,7 +3,7 @@ import os from smithy_core.aio.interfaces.identity import IdentityResolver -from smithy_core.exceptions import SmithyIdentityException +from smithy_core.exceptions import SmithyIdentityError from smithy_core.interfaces.identity import IdentityProperties from ..identity import AWSCredentialsIdentity @@ -29,7 +29,7 @@ async def get_identity( account_id = os.getenv("AWS_ACCOUNT_ID") if access_key_id is None or secret_access_key is None: - raise SmithyIdentityException( + raise SmithyIdentityError( "AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required" ) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/credentials_resolvers/imds.py b/packages/smithy-aws-core/src/smithy_aws_core/credentials_resolvers/imds.py index 6ae6fee09..a3295642a 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/credentials_resolvers/imds.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/credentials_resolvers/imds.py @@ -9,7 +9,7 @@ from smithy_core import URI from smithy_core.aio.interfaces.identity import IdentityResolver -from smithy_core.exceptions import SmithyIdentityException +from smithy_core.exceptions import SmithyIdentityError from smithy_core.interfaces.identity import IdentityProperties from smithy_core.interfaces.retries import RetryStrategy from smithy_core.retries import SimpleRetryStrategy @@ -223,9 +223,7 @@ async def get_identity( expiration = datetime.fromisoformat(expiration).replace(tzinfo=UTC) if access_key_id is None or secret_access_key is None: - raise SmithyIdentityException( - "AccessKeyId and SecretAccessKey are required" - ) + raise SmithyIdentityError("AccessKeyId and SecretAccessKey are required") self._credentials = AWSCredentialsIdentity( access_key_id=access_key_id, diff --git a/packages/smithy-aws-core/tests/unit/credentials_resolvers/test_environment_credentials_resolver.py b/packages/smithy-aws-core/tests/unit/credentials_resolvers/test_environment_credentials_resolver.py index 04f1ab0a2..982396b6e 100644 --- a/packages/smithy-aws-core/tests/unit/credentials_resolvers/test_environment_credentials_resolver.py +++ b/packages/smithy-aws-core/tests/unit/credentials_resolvers/test_environment_credentials_resolver.py @@ -3,12 +3,12 @@ import pytest from smithy_aws_core.credentials_resolvers import EnvironmentCredentialsResolver -from smithy_core.exceptions import SmithyIdentityException +from smithy_core.exceptions import SmithyIdentityError from smithy_core.interfaces.identity import IdentityProperties async def test_no_values_set(): - with pytest.raises(SmithyIdentityException): + with pytest.raises(SmithyIdentityError): await EnvironmentCredentialsResolver().get_identity( identity_properties=IdentityProperties() ) @@ -17,7 +17,7 @@ async def test_no_values_set(): async def test_required_values_missing(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("AWS_ACCOUNT_ID", "123456789012") - with pytest.raises(SmithyIdentityException): + with pytest.raises(SmithyIdentityError): await EnvironmentCredentialsResolver().get_identity( identity_properties=IdentityProperties() ) @@ -26,7 +26,7 @@ async def test_required_values_missing(monkeypatch: pytest.MonkeyPatch): async def test_akid_missing(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "secret") - with pytest.raises(SmithyIdentityException): + with pytest.raises(SmithyIdentityError): await EnvironmentCredentialsResolver().get_identity( identity_properties=IdentityProperties() ) @@ -35,7 +35,7 @@ async def test_akid_missing(monkeypatch: pytest.MonkeyPatch): async def test_secret_missing(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("AWS_ACCESS_KEY_ID", "akid") - with pytest.raises(SmithyIdentityException): + with pytest.raises(SmithyIdentityError): await EnvironmentCredentialsResolver().get_identity( identity_properties=IdentityProperties() ) diff --git a/packages/smithy-aws-event-stream/src/smithy_aws_event_stream/aio/__init__.py b/packages/smithy-aws-event-stream/src/smithy_aws_event_stream/aio/__init__.py index c62fa017c..8d7a17195 100644 --- a/packages/smithy-aws-event-stream/src/smithy_aws_event_stream/aio/__init__.py +++ b/packages/smithy-aws-event-stream/src/smithy_aws_event_stream/aio/__init__.py @@ -9,7 +9,7 @@ from smithy_core.aio.interfaces.eventstream import EventPublisher, EventReceiver from smithy_core.codecs import Codec from smithy_core.deserializers import DeserializeableShape, ShapeDeserializer -from smithy_core.exceptions import ExpectationNotMetException +from smithy_core.exceptions import ExpectationNotMetError from smithy_core.serializers import SerializeableShape from .._private.deserializers import EventDeserializer as _EventDeserializer @@ -53,7 +53,7 @@ async def send(self, event: E) -> None: event.serialize(self._serializer) result = self._serializer.get_result() if result is None: - raise ExpectationNotMetException( + raise ExpectationNotMetError( "Expected an event message to be serialized, but was None." ) if self._signer is not None: diff --git a/packages/smithy-aws-event-stream/src/smithy_aws_event_stream/exceptions.py b/packages/smithy-aws-event-stream/src/smithy_aws_event_stream/exceptions.py index 69293c8b2..1a8b5266d 100644 --- a/packages/smithy-aws-event-stream/src/smithy_aws_event_stream/exceptions.py +++ b/packages/smithy-aws-event-stream/src/smithy_aws_event_stream/exceptions.py @@ -2,13 +2,13 @@ from typing import Any -from smithy_core.exceptions import SmithyException +from smithy_core.exceptions import SmithyError _MAX_HEADERS_LENGTH = 128 * 1024 # 128 Kb _MAX_PAYLOAD_LENGTH = 16 * 1024**2 # 16 Mb -class EventError(SmithyException): +class EventError(SmithyError): """Base error for all errors thrown during event stream handling.""" diff --git a/packages/smithy-aws-event-stream/tests/unit/_private/__init__.py b/packages/smithy-aws-event-stream/tests/unit/_private/__init__.py index 364adef78..0ed16259b 100644 --- a/packages/smithy-aws-event-stream/tests/unit/_private/__init__.py +++ b/packages/smithy-aws-event-stream/tests/unit/_private/__init__.py @@ -6,7 +6,7 @@ from smithy_aws_event_stream.events import Byte, EventMessage, Long, Short from smithy_core.deserializers import ShapeDeserializer -from smithy_core.exceptions import SmithyException +from smithy_core.exceptions import SmithyError from smithy_core.prelude import ( BLOB, BOOLEAN, @@ -232,7 +232,7 @@ def _consumer(schema: Schema, de: ShapeDeserializer) -> None: ) case _: - raise SmithyException(f"Unexpected member schema: {schema}") + raise SmithyError(f"Unexpected member schema: {schema}") deserializer.read_struct(schema=SCHEMA_MESSAGE_EVENT, consumer=_consumer) return cls(**kwargs) @@ -277,7 +277,7 @@ def _consumer(schema: Schema, de: ShapeDeserializer) -> None: SCHEMA_PAYLOAD_EVENT.members["payload"] ) case _: - raise SmithyException(f"Unexpected member schema: {schema}") + raise SmithyError(f"Unexpected member schema: {schema}") deserializer.read_struct(schema=SCHEMA_PAYLOAD_EVENT, consumer=_consumer) return cls(**kwargs) @@ -326,7 +326,7 @@ def _consumer(schema: Schema, de: ShapeDeserializer) -> None: SCHEMA_BLOB_PAYLOAD_EVENT.members["payload"] ) case _: - raise SmithyException(f"Unexpected member schema: {schema}") + raise SmithyError(f"Unexpected member schema: {schema}") deserializer.read_struct(schema=SCHEMA_BLOB_PAYLOAD_EVENT, consumer=_consumer) return cls(**kwargs) @@ -368,7 +368,7 @@ def _consumer(schema: Schema, de: ShapeDeserializer) -> None: SCHEMA_ERROR_EVENT.members["message"] ) case _: - raise SmithyException(f"Unexpected member schema: {schema}") + raise SmithyError(f"Unexpected member schema: {schema}") deserializer.read_struct(schema=SCHEMA_ERROR_EVENT, consumer=_consumer) return cls(**kwargs) @@ -390,10 +390,10 @@ class EventStreamUnknownEvent: tag: str def serialize(self, serializer: ShapeSerializer): - raise SmithyException("Unknown union variants may not be serialized.") + raise SmithyError("Unknown union variants may not be serialized.") def serialize_members(self, serializer: ShapeSerializer): - raise SmithyException("Unknown union variants may not be serialized.") + raise SmithyError("Unknown union variants may not be serialized.") type EventStream = ( @@ -413,7 +413,7 @@ def deserialize(self, deserializer: ShapeDeserializer) -> EventStream: deserializer.read_struct(SCHEMA_EVENT_STREAM, self._consumer) if self._result is None: - raise SmithyException("Unions must have exactly one value, but found none.") + raise SmithyError("Unions must have exactly one value, but found none.") return self._result @@ -434,11 +434,11 @@ def _consumer(self, schema: Schema, de: ShapeDeserializer) -> None: self._set_result(EventStreamErrorEvent(ErrorEvent.deserialize(de))) case _: - raise SmithyException(f"Unexpected member schema: {schema}") + raise SmithyError(f"Unexpected member schema: {schema}") def _set_result(self, value: EventStream) -> None: if self._result is not None: - raise SmithyException( + raise SmithyError( "Unions must have exactly one value, but found more than one." ) self._result = value @@ -466,7 +466,7 @@ def _consumer(schema: Schema, de: ShapeDeserializer) -> None: SCHEMA_INITIAL_MESSAGE.members["message"] ) case _: - raise SmithyException(f"Unexpected member schema: {schema}") + raise SmithyError(f"Unexpected member schema: {schema}") deserializer.read_struct(schema=SCHEMA_INITIAL_MESSAGE, consumer=_consumer) return cls(**kwargs) diff --git a/packages/smithy-core/src/smithy_core/__init__.py b/packages/smithy-core/src/smithy_core/__init__.py index 359d9cf3c..2a4dd11b1 100644 --- a/packages/smithy-core/src/smithy_core/__init__.py +++ b/packages/smithy-core/src/smithy_core/__init__.py @@ -7,7 +7,7 @@ from urllib.parse import urlunparse from . import interfaces, rfc3986 -from .exceptions import SmithyException +from .exceptions import SmithyError __version__: str = importlib.metadata.version("smithy-core") @@ -61,7 +61,7 @@ def __post_init__(self) -> None: if not rfc3986.HOST_MATCHER.match(self.host) and not rfc3986.IPv6_MATCHER.match( f"[{self.host}]" ): - raise SmithyException(f"Invalid host: {self.host}") + raise SmithyError(f"Invalid host: {self.host}") @property def netloc(self) -> str: diff --git a/packages/smithy-core/src/smithy_core/aio/interfaces/__init__.py b/packages/smithy-core/src/smithy_core/aio/interfaces/__init__.py index 6f64fa8ff..89feb03c4 100644 --- a/packages/smithy-core/src/smithy_core/aio/interfaces/__init__.py +++ b/packages/smithy-core/src/smithy_core/aio/interfaces/__init__.py @@ -5,7 +5,7 @@ from ...documents import TypeRegistry from ...endpoints import EndpointResolverParams -from ...exceptions import UnsupportedStreamException +from ...exceptions import UnsupportedStreamError from ...interfaces import Endpoint, TypedProperties, URI from ...interfaces import StreamingBlob as SyncStreamingBlob from .eventstream import EventPublisher, EventReceiver @@ -172,7 +172,7 @@ def create_event_publisher[ :param event_type: The type of event to publish. :param context: A context bag for the request. """ - raise UnsupportedStreamException() + raise UnsupportedStreamError() def create_event_receiver[ OperationInput: "SerializeableShape", @@ -197,4 +197,4 @@ def create_event_receiver[ :param event_deserializer: The deserializer to be used to deserialize events. :param context: A context bag for the request. """ - raise UnsupportedStreamException() + raise UnsupportedStreamError() diff --git a/packages/smithy-core/src/smithy_core/aio/types.py b/packages/smithy-core/src/smithy_core/aio/types.py index 91e0244b9..59c01b476 100644 --- a/packages/smithy-core/src/smithy_core/aio/types.py +++ b/packages/smithy-core/src/smithy_core/aio/types.py @@ -7,7 +7,7 @@ from io import BytesIO from typing import Any, Self, cast -from ..exceptions import SmithyException +from ..exceptions import SmithyError from ..interfaces import BytesReader from .interfaces import AsyncByteStream, StreamingBlob from .utils import close @@ -312,7 +312,7 @@ def __init__( async def write(self, data: bytes) -> None: if self._closed: - raise SmithyException("Attempted to write to a closed provider.") + raise SmithyError("Attempted to write to a closed provider.") # Acquire a lock on the data buffer, releasing it automatically when the # block exits. @@ -327,9 +327,7 @@ async def write(self, data: bytes) -> None: if self._closed or self._closing: # Notify to allow other coroutines to check their conditions. self._data_condition.notify() - raise SmithyException( - "Attempted to write to a closed or closing provider." - ) + raise SmithyError("Attempted to write to a closed or closing provider.") # Add a new chunk of data to the buffer and notify the next waiting # coroutine. diff --git a/packages/smithy-core/src/smithy_core/aio/utils.py b/packages/smithy-core/src/smithy_core/aio/utils.py index b88044376..28fd36731 100644 --- a/packages/smithy-core/src/smithy_core/aio/utils.py +++ b/packages/smithy-core/src/smithy_core/aio/utils.py @@ -4,7 +4,7 @@ from collections.abc import AsyncIterable, Iterable from typing import Any -from ..exceptions import AsyncBodyException +from ..exceptions import AsyncBodyError from ..interfaces import BytesReader from ..interfaces import StreamingBlob as SyncStreamingBlob from .interfaces import AsyncByteStream, StreamingBlob @@ -38,7 +38,7 @@ def read_streaming_blob(body: StreamingBlob) -> bytes: """Synchronously reads a streaming blob into bytes. :param body: The streaming blob to read from. - :raises AsyncBodyException: If the body is an async type. + :raises AsyncBodyError: If the body is an async type. """ match body: case bytes(): @@ -48,7 +48,7 @@ def read_streaming_blob(body: StreamingBlob) -> bytes: case BytesReader(): return body.read() case _: - raise AsyncBodyException( + raise AsyncBodyError( f"Expected type {SyncStreamingBlob}, but was {type(body)}" ) diff --git a/packages/smithy-core/src/smithy_core/deserializers.py b/packages/smithy-core/src/smithy_core/deserializers.py index 6367af5bd..4a9f9dc58 100644 --- a/packages/smithy-core/src/smithy_core/deserializers.py +++ b/packages/smithy-core/src/smithy_core/deserializers.py @@ -3,7 +3,7 @@ from decimal import Decimal from typing import TYPE_CHECKING, Never, Protocol, Self, runtime_checkable -from .exceptions import SmithyException, UnsupportedStreamException +from .exceptions import SmithyError, UnsupportedStreamError if TYPE_CHECKING: from .aio.interfaces import StreamingBlob as _Stream @@ -186,7 +186,7 @@ def read_data_stream(self, schema: "Schema") -> "_Stream": :param schema: The shape's schema. :returns: A data stream derived from the underlying data. """ - raise UnsupportedStreamException() + raise UnsupportedStreamError() class SpecificShapeDeserializer(ShapeDeserializer): @@ -198,7 +198,7 @@ def _invalid_state( ) -> Never: if message is None: message = f"Unexpected schema type: {schema}" - raise SmithyException(message) + raise SmithyError(message) def read_struct( self, diff --git a/packages/smithy-core/src/smithy_core/documents.py b/packages/smithy-core/src/smithy_core/documents.py index a07304f4a..2166bae1e 100644 --- a/packages/smithy-core/src/smithy_core/documents.py +++ b/packages/smithy-core/src/smithy_core/documents.py @@ -5,7 +5,7 @@ from typing import TypeGuard, override from .deserializers import DeserializeableShape, ShapeDeserializer -from .exceptions import ExpectationNotMetException, SmithyException +from .exceptions import ExpectationNotMetError, SmithyError from .schemas import Schema from .serializers import ( InterceptingSerializer, @@ -177,7 +177,7 @@ def as_integer(self) -> int: """ if isinstance(self._value, int) and not isinstance(self._value, bool): return self._value - raise ExpectationNotMetException( + raise ExpectationNotMetError( f"Expected int, found {type(self._value)}: {self._value}" ) @@ -204,7 +204,7 @@ def as_list(self) -> list["Document"]: ): self._value = self._wrap_list(self._raw_value) return self._value - raise ExpectationNotMetException( + raise ExpectationNotMetError( f"Expected list, found {type(self._value)}: {self._value}" ) @@ -228,7 +228,7 @@ def as_map(self) -> dict[str, "Document"]: if self._value is None and isinstance(self._raw_value, Mapping): self._value = self._wrap_map(self._raw_value) return self._value - raise ExpectationNotMetException( + raise ExpectationNotMetError( f"Expected map, found {type(self._value)}: {self._value}" ) @@ -317,11 +317,11 @@ def serialize_contents(self, serializer: ShapeSerializer) -> None: case ShapeType.DOCUMENT: # The shape type is only ever document when the value is null, # which is a case we've already handled. - raise SmithyException( + raise SmithyError( f"Unexpexcted DOCUMENT shape type for document value: {self.as_value()}" ) case _: - raise SmithyException( + raise SmithyError( f"Unexpected {self._type} shape type for document value: {self.as_value()}" ) @@ -428,7 +428,7 @@ class _DocumentSerializer(ShapeSerializer): def expect_result(self) -> Document: """Expect a document to have been serialized and return it.""" if self.result is None: - raise ExpectationNotMetException( + raise ExpectationNotMetError( "Expected document serializer to have a result, but was None" ) return self.result @@ -603,7 +603,7 @@ def is_null(self) -> bool: @override def read_null(self) -> None: if (value := self._value.as_value()) is not None: - raise ExpectationNotMetException( + raise ExpectationNotMetError( f"Expected document value to be None, but was: {value}" ) diff --git a/packages/smithy-core/src/smithy_core/exceptions.py b/packages/smithy-core/src/smithy_core/exceptions.py index 13741c241..7d320b76c 100644 --- a/packages/smithy-core/src/smithy_core/exceptions.py +++ b/packages/smithy-core/src/smithy_core/exceptions.py @@ -4,7 +4,7 @@ from typing import Literal -class SmithyException(Exception): +class SmithyError(Exception): """Base exception type for all exceptions raised by smithy-python.""" @@ -16,7 +16,7 @@ class SmithyException(Exception): @dataclass(kw_only=True) -class CallException(SmithyException): +class CallError(SmithyError): """Base exception to be used in application-level errors. Implements :py:class:`.interfaces.retries.ErrorRetryInfo`. @@ -55,42 +55,42 @@ def __post_init__(self): @dataclass(kw_only=True) -class ModeledException(CallException): +class ModeledError(CallError): """Base exception to be used for modeled errors.""" fault: Fault = "client" -class SerializationException(Exception): +class SerializationError(SmithyError): """Base exception type for exceptions raised during serialization.""" -class SmithyRetryException(SmithyException): +class RetryError(SmithyError): """Base exception type for all exceptions raised in retry strategies.""" -class ExpectationNotMetException(SmithyException): +class ExpectationNotMetError(SmithyError): """Exception type for exceptions thrown by unmet assertions.""" -class SmithyIdentityException(SmithyException): +class SmithyIdentityError(SmithyError): """Base exception type for all exceptions raised in identity resolution.""" -class MissingDependencyException(SmithyException): +class MissingDependencyError(SmithyError): """Exception type raised when a feature that requires a missing optional dependency is called.""" -class AsyncBodyException(SmithyException): +class AsyncBodyError(SmithyError): """Exception indicating that a request with an async body type was created in a sync context.""" -class UnsupportedStreamException(SmithyException): +class UnsupportedStreamError(SmithyError): """Indicates that a serializer or deserializer's stream method was called, but data streams are not supported.""" -class EndpointResolutionError(SmithyException): +class EndpointResolutionError(SmithyError): """Exception type for all exceptions raised by endpoint resolution.""" diff --git a/packages/smithy-core/src/smithy_core/interfaces/retries.py b/packages/smithy-core/src/smithy_core/interfaces/retries.py index cb3b66f27..a5c9d428b 100644 --- a/packages/smithy-core/src/smithy_core/interfaces/retries.py +++ b/packages/smithy-core/src/smithy_core/interfaces/retries.py @@ -6,10 +6,10 @@ @runtime_checkable class ErrorRetryInfo(Protocol): - """A protocol for exceptions that have retry information embedded.""" + """A protocol for errors that have retry information embedded.""" is_retry_safe: bool | None = None - """Whether the exception is safe to retry. + """Whether the error is safe to retry. A value of True does not mean a retry will occur, but rather that a retry is allowed to occur. @@ -70,7 +70,7 @@ def acquire_initial_retry_token( separate tokens into scopes. :returns: A retry token, to be used for determining the retry delay, refreshing the token after a failure, and recording success after success. - :raises SmithyRetryException: If the retry strategy has no available tokens. + :raises RetryError: If the retry strategy has no available tokens. """ ... @@ -83,11 +83,11 @@ def refresh_retry_token_for_retry( that was previously obtained by calling :py:func:`acquire_initial_retry_token` or this method with a new retry token for the next attempt. This method can either choose to allow another retry and send a new or updated token, or reject - the retry attempt and raise the error as exception. + the retry attempt and raise the error. :param token_to_renew: The token used for the previous failed attempt. :param error: The error that triggered the need for a retry. - :raises SmithyRetryException: If no further retry attempts are allowed. + :raises RetryError: If no further retry attempts are allowed. """ ... diff --git a/packages/smithy-core/src/smithy_core/retries.py b/packages/smithy-core/src/smithy_core/retries.py index 117ba604c..f3c79798b 100644 --- a/packages/smithy-core/src/smithy_core/retries.py +++ b/packages/smithy-core/src/smithy_core/retries.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from enum import Enum -from .exceptions import SmithyRetryException +from .exceptions import RetryError from .interfaces import retries as retries_interface @@ -227,18 +227,18 @@ def refresh_retry_token_for_retry( :param token_to_renew: The token used for the previous failed attempt. :param error: The error that triggered the need for a retry. - :raises SmithyRetryException: If no further retry attempts are allowed. + :raises RetryError: If no further retry attempts are allowed. """ if isinstance(error, retries_interface.ErrorRetryInfo) and error.is_retry_safe: retry_count = token_to_renew.retry_count + 1 if retry_count >= self.max_attempts: - raise SmithyRetryException( + raise RetryError( f"Reached maximum number of allowed attempts: {self.max_attempts}" ) retry_delay = self.backoff_strategy.compute_next_backoff_delay(retry_count) return SimpleRetryToken(retry_count=retry_count, retry_delay=retry_delay) else: - raise SmithyRetryException(f"Error is not retryable: {error}") + raise RetryError(f"Error is not retryable: {error}") def record_success(self, *, token: retries_interface.RetryToken) -> None: """Not used by this retry strategy.""" diff --git a/packages/smithy-core/src/smithy_core/schemas.py b/packages/smithy-core/src/smithy_core/schemas.py index df44687c0..2c2429502 100644 --- a/packages/smithy-core/src/smithy_core/schemas.py +++ b/packages/smithy-core/src/smithy_core/schemas.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING, Any, NotRequired, Required, Self, TypedDict, overload -from .exceptions import ExpectationNotMetException, SmithyException +from .exceptions import ExpectationNotMetError, SmithyError from .shapes import ShapeID, ShapeType from .traits import DynamicTrait, IdempotencyTokenTrait, StreamingTrait, Trait @@ -55,7 +55,7 @@ def __init__( member_index is not None, ] if any(_member_props) and not all(_member_props): - raise SmithyException( + raise SmithyError( "If any member property is set, all member properties must be set. " f"member_name: {id.member!r}, member_target: " f"{member_target!r}, member_index: {member_index!r}" @@ -96,7 +96,7 @@ def member_name(self) -> str | None: def expect_member_name(self) -> str: """Assert the schema is a member schema and return its member name. - :raises ExpectationNotMetException: If member_name wasn't set. + :raises ExpectationNotMetError: If member_name wasn't set. :returns: Returns the member name. """ return self.id.expect_member() @@ -107,11 +107,11 @@ def expect_member_target(self) -> "Schema": If the target is a class containing a schema, the schema is extracted and returned. - :raises ExpectationNotMetException: If member_target wasn't set. + :raises ExpectationNotMetError: If member_target wasn't set. :returns: Returns the target schema. """ if self.member_target is None: - raise ExpectationNotMetException( + raise ExpectationNotMetError( "Expected member_target to be set, but was None." ) return self.member_target @@ -119,11 +119,11 @@ def expect_member_target(self) -> "Schema": def expect_member_index(self) -> int: """Assert the schema is a member schema and return its member index. - :raises ExpectationNotMetException: If member_index wasn't set. + :raises ExpectationNotMetError: If member_index wasn't set. :returns: Returns the member index. """ if self.member_index is None: - raise ExpectationNotMetException( + raise ExpectationNotMetError( "Expected member_index to be set, but was None." ) return self.member_index @@ -243,7 +243,7 @@ def member( """ id.expect_member() if target.member_target is not None: - raise ExpectationNotMetException("Member targets must not be members.") + raise ExpectationNotMetError("Member targets must not be members.") resolved_traits = target.traits.copy() if member_traits: resolved_traits.update({t.id: t for t in member_traits}) diff --git a/packages/smithy-core/src/smithy_core/serializers.py b/packages/smithy-core/src/smithy_core/serializers.py index e6525d59b..8c4e20df6 100644 --- a/packages/smithy-core/src/smithy_core/serializers.py +++ b/packages/smithy-core/src/smithy_core/serializers.py @@ -5,7 +5,7 @@ from decimal import Decimal from typing import TYPE_CHECKING, Never, Protocol, runtime_checkable -from .exceptions import SmithyException, UnsupportedStreamException +from .exceptions import SmithyError, UnsupportedStreamError if TYPE_CHECKING: from .aio.interfaces import StreamingBlob as _Stream @@ -215,7 +215,7 @@ def write_data_stream(self, schema: "Schema", value: "_Stream") -> None: """ if isinstance(value, bytes | bytearray): self.write_blob(schema, bytes(value)) - raise UnsupportedStreamException() + raise UnsupportedStreamError() def flush(self) -> None: """Flush the underlying data.""" @@ -357,7 +357,7 @@ def _invalid_state( ) -> Never: if message is None: message = f"Unexpected schema type: {schema}" - raise SmithyException(message) + raise SmithyError(message) def begin_struct( self, schema: "Schema" diff --git a/packages/smithy-core/src/smithy_core/shapes.py b/packages/smithy-core/src/smithy_core/shapes.py index 3f9505165..0f25383fe 100644 --- a/packages/smithy-core/src/smithy_core/shapes.py +++ b/packages/smithy-core/src/smithy_core/shapes.py @@ -1,7 +1,7 @@ from enum import Enum from typing import Self -from .exceptions import ExpectationNotMetException, SmithyException +from .exceptions import ExpectationNotMetError, SmithyError class ShapeID: @@ -19,15 +19,15 @@ def __init__(self, id: str) -> None: """ self._id = id if "#" not in id: - raise SmithyException(f"Invalid shape id: {id}") + raise SmithyError(f"Invalid shape id: {id}") self._namespace, self._name = id.split("#", 1) if not self.namespace or not self._name: - raise SmithyException(f"Invalid shape id: {id}") + raise SmithyError(f"Invalid shape id: {id}") if len(split_name := self._name.split("$", 1)) > 1: self._name, self._member = split_name if not self._name or not self._member: - raise SmithyException(f"Invalid shape id: {id}") + raise SmithyError(f"Invalid shape id: {id}") @property def namespace(self) -> str: @@ -55,7 +55,7 @@ def expect_member(self) -> str: :returns: Returns the member name. """ if self.member is None: - raise ExpectationNotMetException("Expected member to be set, but was None.") + raise ExpectationNotMetError("Expected member to be set, but was None.") return self.member def with_member(self, member: str) -> "ShapeID": diff --git a/packages/smithy-core/src/smithy_core/types.py b/packages/smithy-core/src/smithy_core/types.py index 19c2ae01b..e91da0b1d 100644 --- a/packages/smithy-core/src/smithy_core/types.py +++ b/packages/smithy-core/src/smithy_core/types.py @@ -11,7 +11,7 @@ from enum import Enum from typing import Any, overload -from .exceptions import ExpectationNotMetException +from .exceptions import ExpectationNotMetError from .interfaces import PropertyKey as _PropertyKey from .interfaces import TypedProperties as _TypedProperties from .utils import ( @@ -113,7 +113,7 @@ def deserialize(self, value: str | float) -> datetime: try: value = float(value) except ValueError as e: - raise ExpectationNotMetException from e + raise ExpectationNotMetError from e return epoch_seconds_to_datetime(value=value) case TimestampFormat.HTTP_DATE: return ensure_utc(parsedate_to_datetime(expect_type(str, value))) diff --git a/packages/smithy-core/src/smithy_core/utils.py b/packages/smithy-core/src/smithy_core/utils.py index f063ad1aa..ab981a494 100644 --- a/packages/smithy-core/src/smithy_core/utils.py +++ b/packages/smithy-core/src/smithy_core/utils.py @@ -7,7 +7,7 @@ from types import UnionType from typing import Any, TypeVar, overload -from .exceptions import ExpectationNotMetException +from .exceptions import ExpectationNotMetError RFC3339 = "%Y-%m-%dT%H:%M:%SZ" # Same as RFC3339, but with microsecond precision. @@ -41,7 +41,7 @@ def limited_parse_float(value: Any) -> float: :param value: An object that is expected to be a float. :returns: The given value as a float. - :raises SmithyException: If the value is not a float or one of the strings ``NaN``, + :raises SmithyError: If the value is not a float or one of the strings ``NaN``, ``Infinity``, or ``-Infinity``. """ # TODO: add limited bounds checking @@ -92,12 +92,10 @@ def expect_type(typ: UnionType | type, value: Any) -> Any: :param typ: The expected type. :param value: The value which is expected to be the given type. :returns: The given value cast as the given type. - :raises SmithyException: If the value does not match the type. + :raises SmithyError: If the value does not match the type. """ if not isinstance(value, typ): - raise ExpectationNotMetException( - f"Expected {typ}, found {type(value)}: {value}" - ) + raise ExpectationNotMetError(f"Expected {typ}, found {type(value)}: {value}") return value @@ -118,8 +116,7 @@ def strict_parse_bool(given: str) -> bool: :param given: A string that is expected to contain either "true" or "false". :returns: The given string parsed to a boolean. - :raises ExpectationNotMetException: if the given string is neither "true" nor - "false". + :raises ExpectationNotMetError: if the given string is neither "true" nor "false". """ match given: case "true": @@ -127,9 +124,7 @@ def strict_parse_bool(given: str) -> bool: case "false": return False case _: - raise ExpectationNotMetException( - f"Expected 'true' or 'false', found: {given}" - ) + raise ExpectationNotMetError(f"Expected 'true' or 'false', found: {given}") # A regex for Smithy floats. It matches JSON-style numbers. @@ -161,11 +156,11 @@ def strict_parse_float(given: str) -> float: :param given: A string that is expected to contain a float. :returns: The given string parsed to a float. - :raises ExpectationNotMetException: If the given string isn't a float. + :raises ExpectationNotMetError: If the given string isn't a float. """ if _FLOAT_REGEX.fullmatch(given): return float(given) - raise ExpectationNotMetException(f"Expected float, found: {given}") + raise ExpectationNotMetError(f"Expected float, found: {given}") def serialize_float(given: float | Decimal) -> str: diff --git a/packages/smithy-core/tests/unit/aio/test_types.py b/packages/smithy-core/tests/unit/aio/test_types.py index c104be378..47977add1 100644 --- a/packages/smithy-core/tests/unit/aio/test_types.py +++ b/packages/smithy-core/tests/unit/aio/test_types.py @@ -11,7 +11,7 @@ AsyncBytesReader, SeekableAsyncBytesReader, ) -from smithy_core.exceptions import SmithyException +from smithy_core.exceptions import SmithyError class _AsyncIteratorWrapper: @@ -379,7 +379,7 @@ async def test_provider_reads_written_data() -> None: async def test_close_stops_writes() -> None: provider = AsyncBytesProvider() await provider.close() - with pytest.raises(SmithyException): + with pytest.raises(SmithyError): await provider.write(b"foo") @@ -443,7 +443,7 @@ async def test_close_stops_queued_writes() -> None: # Now close the provider. The write task will raise an error. await provider.close(flush=False) - with pytest.raises(SmithyException): + with pytest.raises(SmithyError): await write_task @@ -478,7 +478,7 @@ async def test_close_with_flush() -> None: # only see the initial data. The write task will raise an exception as the # provider closed before it could write its data. assert result == [b"foo"] - with pytest.raises(SmithyException): + with pytest.raises(SmithyError): await write_task diff --git a/packages/smithy-core/tests/unit/test_documents.py b/packages/smithy-core/tests/unit/test_documents.py index f2a065757..1ae24eb94 100644 --- a/packages/smithy-core/tests/unit/test_documents.py +++ b/packages/smithy-core/tests/unit/test_documents.py @@ -12,7 +12,7 @@ _DocumentDeserializer, _DocumentSerializer, ) -from smithy_core.exceptions import ExpectationNotMetException +from smithy_core.exceptions import ExpectationNotMetError from smithy_core.prelude import ( BIG_DECIMAL, BLOB, @@ -77,7 +77,7 @@ def test_as_blob() -> None: ], ) def test_as_blob_invalid(value: DocumentValue) -> None: - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): Document(value).as_blob() @@ -100,7 +100,7 @@ def test_as_boolean() -> None: ], ) def test_as_boolean_invalid(value: DocumentValue) -> None: - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): Document(value).as_boolean() @@ -123,7 +123,7 @@ def test_as_string() -> None: ], ) def test_as_string_invalid(value: DocumentValue) -> None: - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): Document(value).as_string() @@ -146,7 +146,7 @@ def test_as_timestamp() -> None: ], ) def test_as_timestamp_invalid(value: DocumentValue) -> None: - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): Document(value).as_timestamp() @@ -169,7 +169,7 @@ def test_as_integer() -> None: ], ) def test_as_integer_invalid(value: DocumentValue) -> None: - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): Document(value).as_integer() @@ -192,7 +192,7 @@ def test_as_float() -> None: ], ) def test_as_float_invalid(value: DocumentValue) -> None: - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): Document(value).as_float() @@ -215,7 +215,7 @@ def test_as_decimal() -> None: ], ) def test_as_decimal_invalid(value: DocumentValue) -> None: - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): Document(value).as_decimal() @@ -239,7 +239,7 @@ def test_as_list() -> None: ], ) def test_as_list_invalid(value: DocumentValue) -> None: - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): Document(value).as_list() @@ -263,7 +263,7 @@ def test_as_map() -> None: ], ) def test_as_map_invalid(value: DocumentValue) -> None: - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): Document(value).as_map() @@ -297,7 +297,7 @@ def test_get_from_map() -> None: with pytest.raises(KeyError): document["baz"] - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): document[0] assert document.get("foo") == Document("bar") @@ -309,7 +309,7 @@ def test_slice_map() -> None: document = Document({"foo": "bar"}) assert document.as_value() == {"foo": "bar"} - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): document[1:] @@ -319,10 +319,10 @@ def test_get_from_list() -> None: with pytest.raises(IndexError): document[1] - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): document["foo"] - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): document.get(1) # type: ignore @@ -358,7 +358,7 @@ def test_insert_into_map() -> None: "eggs": "spam", } - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): document[0] = "foo" @@ -374,7 +374,7 @@ def test_insert_into_list() -> None: assert document.as_value() == ["foo", "spam", "eggs"] - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): document["foo"] = "bar" diff --git a/packages/smithy-core/tests/unit/test_retries.py b/packages/smithy-core/tests/unit/test_retries.py index 3703d4ec1..0b3c23be4 100644 --- a/packages/smithy-core/tests/unit/test_retries.py +++ b/packages/smithy-core/tests/unit/test_retries.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import pytest -from smithy_core.exceptions import CallException, SmithyRetryException +from smithy_core.exceptions import CallError, RetryError from smithy_core.retries import ExponentialBackoffJitterType as EBJT from smithy_core.retries import ExponentialRetryBackoffStrategy, SimpleRetryStrategy @@ -60,13 +60,13 @@ def test_simple_retry_strategy(max_attempts: int) -> None: backoff_strategy=ExponentialRetryBackoffStrategy(backoff_scale_value=5), max_attempts=max_attempts, ) - error = CallException(is_retry_safe=True) + error = CallError(is_retry_safe=True) token = strategy.acquire_initial_retry_token() for _ in range(max_attempts - 1): token = strategy.refresh_retry_token_for_retry( token_to_renew=token, error=error ) - with pytest.raises(SmithyRetryException): + with pytest.raises(RetryError): strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) @@ -76,7 +76,7 @@ def test_simple_retry_does_not_retry_unclassified() -> None: max_attempts=2, ) token = strategy.acquire_initial_retry_token() - with pytest.raises(SmithyRetryException): + with pytest.raises(RetryError): strategy.refresh_retry_token_for_retry(token_to_renew=token, error=Exception()) @@ -85,9 +85,9 @@ def test_simple_retry_does_not_retry_when_safety_unknown() -> None: backoff_strategy=ExponentialRetryBackoffStrategy(backoff_scale_value=5), max_attempts=2, ) - error = CallException(is_retry_safe=None) + error = CallError(is_retry_safe=None) token = strategy.acquire_initial_retry_token() - with pytest.raises(SmithyRetryException): + with pytest.raises(RetryError): strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) @@ -96,7 +96,7 @@ def test_simple_retry_does_not_retry_unsafe() -> None: backoff_strategy=ExponentialRetryBackoffStrategy(backoff_scale_value=5), max_attempts=2, ) - error = CallException(fault="client", is_retry_safe=False) + error = CallError(fault="client", is_retry_safe=False) token = strategy.acquire_initial_retry_token() - with pytest.raises(SmithyRetryException): + with pytest.raises(RetryError): strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) diff --git a/packages/smithy-core/tests/unit/test_schemas.py b/packages/smithy-core/tests/unit/test_schemas.py index 1712a37ca..212a98a46 100644 --- a/packages/smithy-core/tests/unit/test_schemas.py +++ b/packages/smithy-core/tests/unit/test_schemas.py @@ -2,7 +2,7 @@ from typing import Any import pytest -from smithy_core.exceptions import ExpectationNotMetException +from smithy_core.exceptions import ExpectationNotMetError from smithy_core.schemas import Schema from smithy_core.shapes import ShapeID, ShapeType from smithy_core.traits import ( @@ -75,13 +75,13 @@ def test_expect_member_schema(): def test_member_expectations_raise_on_non_members(): - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): STRING.expect_member_name() - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): STRING.expect_member_target() - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): STRING.expect_member_index() @@ -137,7 +137,7 @@ def test_member_constructor(): def test_member_constructor_asserts_id(): - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): Schema.member(id=ShapeID("smithy.example#foo"), target=STRING, index=0) @@ -145,7 +145,7 @@ def test_member_constructor_asserts_target_is_not_member(): target = Schema.member( id=ShapeID("smithy.example#Spam$eggs"), target=STRING, index=0 ) - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): Schema.member(id=ShapeID("smithy.example#Foo$bar"), target=target, index=0) diff --git a/packages/smithy-core/tests/unit/test_shapes.py b/packages/smithy-core/tests/unit/test_shapes.py index 80170f1f9..fd6f25117 100644 --- a/packages/smithy-core/tests/unit/test_shapes.py +++ b/packages/smithy-core/tests/unit/test_shapes.py @@ -1,5 +1,5 @@ import pytest -from smithy_core.exceptions import ExpectationNotMetException, SmithyException +from smithy_core.exceptions import ExpectationNotMetError, SmithyError from smithy_core.shapes import ShapeID @@ -23,13 +23,13 @@ def test_valid_shape_id(id: str, namespace: str, name: str, member: str | None): "id", ["foo", "#", "ns.foo#", "#foo", "ns.foo#bar$", "ns.foo#$baz", "#$"] ) def test_invalid_shape_id(id: str): - with pytest.raises(SmithyException): + with pytest.raises(SmithyError): ShapeID(id) def test_expect_member(): assert ShapeID("ns.foo#bar$baz").expect_member() == "baz" - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): assert ShapeID("ns.foo#bar").expect_member() diff --git a/packages/smithy-core/tests/unit/test_types.py b/packages/smithy-core/tests/unit/test_types.py index afea9cb9d..5eb4aed67 100644 --- a/packages/smithy-core/tests/unit/test_types.py +++ b/packages/smithy-core/tests/unit/test_types.py @@ -6,7 +6,7 @@ from typing import Any, assert_type import pytest -from smithy_core.exceptions import ExpectationNotMetException +from smithy_core.exceptions import ExpectationNotMetError from smithy_core.types import ( JsonBlob, JsonString, @@ -185,7 +185,7 @@ def test_timestamp_format_deserialize( def test_invalid_timestamp_format_type_raises( format: TimestampFormat, value: str | float ): - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): format.deserialize(value) diff --git a/packages/smithy-core/tests/unit/test_uri.py b/packages/smithy-core/tests/unit/test_uri.py index d69df228f..9d7c9db3e 100644 --- a/packages/smithy-core/tests/unit/test_uri.py +++ b/packages/smithy-core/tests/unit/test_uri.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import pytest from smithy_core import HostType, URI -from smithy_core.exceptions import SmithyException +from smithy_core.exceptions import SmithyError def test_uri_basic() -> None: @@ -113,5 +113,5 @@ def test_host_type(input_uri: URI, host_type: HostType) -> None: "input_host", ["example.com\t", "umlaut-äöü.aws.dev", "foo\nbar.com"] ) def test_uri_init_with_disallowed_characters(input_host: str) -> None: - with pytest.raises(SmithyException): + with pytest.raises(SmithyError): URI(host=input_host) diff --git a/packages/smithy-core/tests/unit/test_utils.py b/packages/smithy-core/tests/unit/test_utils.py index 208ffb3b9..b5804cc47 100644 --- a/packages/smithy-core/tests/unit/test_utils.py +++ b/packages/smithy-core/tests/unit/test_utils.py @@ -11,7 +11,7 @@ from unittest.mock import Mock import pytest -from smithy_core.exceptions import ExpectationNotMetException +from smithy_core.exceptions import ExpectationNotMetError from smithy_core.utils import ( ensure_utc, epoch_seconds_to_datetime, @@ -71,7 +71,7 @@ def test_expect_type(typ: Any, value: Any) -> None: ], ) def test_expect_type_raises(typ: Any, value: Any) -> None: - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): expect_type(typ, value) @@ -100,7 +100,7 @@ def test_limited_parse_float(given: float | str, expected: float) -> None: ], ) def test_limited_parse_float_raises(given: float | str) -> None: - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): limited_parse_float(given) @@ -111,7 +111,7 @@ def test_limited_parse_float_nan() -> None: def test_strict_parse_bool() -> None: assert strict_parse_bool("true") is True assert strict_parse_bool("false") is False - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): strict_parse_bool("") @@ -150,7 +150,7 @@ def test_strict_parse_float_nan() -> None: ], ) def test_strict_parse_float_raises(given: str) -> None: - with pytest.raises(ExpectationNotMetException): + with pytest.raises(ExpectationNotMetError): strict_parse_float(given) diff --git a/packages/smithy-http/src/smithy_http/aio/aiohttp.py b/packages/smithy-http/src/smithy_http/aio/aiohttp.py index f35ad7fbb..83f4c191f 100644 --- a/packages/smithy-http/src/smithy_http/aio/aiohttp.py +++ b/packages/smithy-http/src/smithy_http/aio/aiohttp.py @@ -23,7 +23,7 @@ from smithy_core.aio.interfaces import StreamingBlob from smithy_core.aio.types import AsyncBytesReader from smithy_core.aio.utils import async_list -from smithy_core.exceptions import MissingDependencyException +from smithy_core.exceptions import MissingDependencyError from smithy_core.interfaces import URI from .. import Field, Fields @@ -39,7 +39,7 @@ def _assert_aiohttp() -> None: if not HAS_AIOHTTP: - raise MissingDependencyException( + raise MissingDependencyError( "Attempted to use aiohttp component, but aiohttp is not installed." ) diff --git a/packages/smithy-http/src/smithy_http/aio/auth/apikey.py b/packages/smithy-http/src/smithy_http/aio/auth/apikey.py index 592f872cc..2861357d5 100644 --- a/packages/smithy-http/src/smithy_http/aio/auth/apikey.py +++ b/packages/smithy-http/src/smithy_http/aio/auth/apikey.py @@ -6,7 +6,7 @@ from smithy_core import URI from smithy_core.aio.interfaces.identity import IdentityResolver -from smithy_core.exceptions import SmithyIdentityException +from smithy_core.exceptions import SmithyIdentityError from smithy_core.interfaces.identity import IdentityProperties from ... import Field @@ -73,7 +73,7 @@ def identity_resolver( self, *, config: ApiKeyConfig ) -> IdentityResolver[ApiKeyIdentity, IdentityProperties]: if not config.api_key_identity_resolver: - raise SmithyIdentityException( + raise SmithyIdentityError( "Attempted to use API key auth, but api_key_identity_resolver was not " "set on the config." ) diff --git a/packages/smithy-http/src/smithy_http/aio/crt.py b/packages/smithy-http/src/smithy_http/aio/crt.py index 9f0b5a418..c5e6306b2 100644 --- a/packages/smithy-http/src/smithy_http/aio/crt.py +++ b/packages/smithy-http/src/smithy_http/aio/crt.py @@ -35,18 +35,18 @@ from smithy_core import interfaces as core_interfaces from smithy_core.aio.types import AsyncBytesReader from smithy_core.aio.utils import close -from smithy_core.exceptions import MissingDependencyException +from smithy_core.exceptions import MissingDependencyError from .. import Field, Fields from .. import interfaces as http_interfaces -from ..exceptions import SmithyHTTPException +from ..exceptions import SmithyHTTPError from ..interfaces import FieldPosition from . import interfaces as http_aio_interfaces def _assert_crt() -> None: if not HAS_CRT: - raise MissingDependencyException( + raise MissingDependencyError( "Attempted to use awscrt component, but awscrt is not installed." ) @@ -117,7 +117,7 @@ def __init__(self) -> None: def set_stream(self, stream: "crt_http.HttpClientStream") -> None: if self._stream is not None: - raise SmithyHTTPException("Stream already set on AWSCRTHTTPResponse object") + raise SmithyHTTPError("Stream already set on AWSCRTHTTPResponse object") self._stream = stream concurrent_future: ConcurrentFuture[int] = stream.completion_future self._completion_future = asyncio.wrap_future(concurrent_future) @@ -134,7 +134,7 @@ def on_body(self, chunk: bytes, **kwargs: Any) -> None: # pragma: crt-callback async def next(self) -> bytes: if self._completion_future is None: - raise SmithyHTTPException("Stream not set") + raise SmithyHTTPError("Stream not set") # TODO: update backpressure window once CRT supports it if self._received_chunks: @@ -299,7 +299,7 @@ def _build_new_connection( # TODO: Support TLS configuration, including alpn tls_connection_options.set_alpn_list(["h2", "http/1.1"]) else: - raise SmithyHTTPException( + raise SmithyHTTPError( f"AWSCRTHTTPClient does not support URL scheme {url.scheme}" ) if url.port is not None: @@ -326,7 +326,7 @@ def _validate_connection(self, connection: "crt_http.HttpClientConnection") -> N if force_http_2 and connection.version is not crt_http.HttpVersion.Http2: connection.close() negotiated = crt_http.HttpVersion(connection.version).name - raise SmithyHTTPException(f"HTTP/2 could not be negotiated: {negotiated}") + raise SmithyHTTPError(f"HTTP/2 could not be negotiated: {negotiated}") def _render_path(self, url: core_interfaces.URI) -> str: path = url.path if url.path is not None else "/" diff --git a/packages/smithy-http/src/smithy_http/aio/protocols.py b/packages/smithy-http/src/smithy_http/aio/protocols.py index 6d6d5d4f2..e038b6f01 100644 --- a/packages/smithy-http/src/smithy_http/aio/protocols.py +++ b/packages/smithy-http/src/smithy_http/aio/protocols.py @@ -6,7 +6,7 @@ from smithy_core.codecs import Codec from smithy_core.deserializers import DeserializeableShape from smithy_core.documents import TypeRegistry -from smithy_core.exceptions import ExpectationNotMetException +from smithy_core.exceptions import ExpectationNotMetError from smithy_core.interfaces import Endpoint, TypedProperties, URI from smithy_core.schemas import APIOperation from smithy_core.serializers import SerializeableShape @@ -76,7 +76,7 @@ def serialize_request[ request = serializer.result if request is None: - raise ExpectationNotMetException( + raise ExpectationNotMetError( "Expected request to be serialized, but was None" ) diff --git a/packages/smithy-http/src/smithy_http/deserializers.py b/packages/smithy-http/src/smithy_http/deserializers.py index 6e60e1296..0ddd63cbf 100644 --- a/packages/smithy-http/src/smithy_http/deserializers.py +++ b/packages/smithy-http/src/smithy_http/deserializers.py @@ -7,7 +7,7 @@ from smithy_core.codecs import Codec from smithy_core.deserializers import ShapeDeserializer, SpecificShapeDeserializer -from smithy_core.exceptions import UnsupportedStreamException +from smithy_core.exceptions import UnsupportedStreamError from smithy_core.interfaces import is_bytes_reader, is_streaming_blob from smithy_core.schemas import Schema from smithy_core.shapes import ShapeType @@ -102,7 +102,7 @@ def _create_payload_deserializer( return RawPayloadDeserializer(body) if not is_streaming_blob(body): - raise UnsupportedStreamException( + raise UnsupportedStreamError( "Unable to read async stream. This stream must be buffered prior " "to creating the deserializer." ) @@ -253,7 +253,7 @@ def _consume_payload(self) -> bytes: return bytes(self._payload) if is_bytes_reader(self._payload): return self._payload.read() - raise UnsupportedStreamException( + raise UnsupportedStreamError( "Unable to read async stream. This stream must be buffered prior " "to creating the deserializer." ) diff --git a/packages/smithy-http/src/smithy_http/exceptions.py b/packages/smithy-http/src/smithy_http/exceptions.py index 08ced3c39..79d5146c2 100644 --- a/packages/smithy-http/src/smithy_http/exceptions.py +++ b/packages/smithy-http/src/smithy_http/exceptions.py @@ -1,7 +1,7 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -from smithy_core.exceptions import SmithyException +from smithy_core.exceptions import SmithyError -class SmithyHTTPException(SmithyException): +class SmithyHTTPError(SmithyError): """Base exception type for all exceptions raised in HTTP clients.""" diff --git a/packages/smithy-http/src/smithy_http/utils.py b/packages/smithy-http/src/smithy_http/utils.py index c01ac3541..5d4fc45e7 100644 --- a/packages/smithy-http/src/smithy_http/utils.py +++ b/packages/smithy-http/src/smithy_http/utils.py @@ -3,7 +3,7 @@ from collections.abc import Sequence from urllib.parse import quote as urlquote -from smithy_core.exceptions import SmithyException +from smithy_core.exceptions import SmithyError def split_header(given: str, handle_unquoted_http_date: bool = False) -> list[str]: @@ -40,7 +40,7 @@ def split_header(given: str, handle_unquoted_http_date: bool = False) -> list[st result.append(entry) if i > len(given) or given[i - 1] != '"': - raise SmithyException( + raise SmithyError( f"Invalid header list syntax: expected end quote but reached end " f"of value: `{given}`" ) @@ -48,7 +48,7 @@ def split_header(given: str, handle_unquoted_http_date: bool = False) -> list[st # Skip until the next comma. excess, i = _consume_until(given, i, ",") if excess.strip(): - raise SmithyException( + raise SmithyError( f"Invalid header list syntax: Found quote contents after " f"end-quote: `{excess}` in `{given}`" ) diff --git a/packages/smithy-http/tests/unit/aio/auth/test_apikey.py b/packages/smithy-http/tests/unit/aio/auth/test_apikey.py index 71280aac2..a489ab329 100644 --- a/packages/smithy-http/tests/unit/aio/auth/test_apikey.py +++ b/packages/smithy-http/tests/unit/aio/auth/test_apikey.py @@ -6,7 +6,7 @@ import pytest from smithy_core import URI from smithy_core.aio.interfaces.identity import IdentityResolver -from smithy_core.exceptions import SmithyIdentityException +from smithy_core.exceptions import SmithyIdentityError from smithy_core.interfaces.identity import IdentityProperties from smithy_http import Field, Fields from smithy_http.aio import HTTPRequest @@ -142,5 +142,5 @@ async def test_auth_scheme_gets_resolver() -> None: async def test_auth_scheme_missing_resolver() -> None: scheme = ApiKeyAuthScheme() - with pytest.raises(SmithyIdentityException): + with pytest.raises(SmithyIdentityError): scheme.identity_resolver(config=ApiKeyConfig()) diff --git a/packages/smithy-http/tests/unit/test_utils.py b/packages/smithy-http/tests/unit/test_utils.py index e3bb9af82..781e74607 100644 --- a/packages/smithy-http/tests/unit/test_utils.py +++ b/packages/smithy-http/tests/unit/test_utils.py @@ -1,7 +1,7 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 import pytest -from smithy_core.exceptions import SmithyException +from smithy_core.exceptions import SmithyError from smithy_http.utils import join_query_params, split_header @@ -56,7 +56,7 @@ def test_split_imf_fixdate_header(given: str, expected: list[str]) -> None: ], ) def test_split_header_raises(given: str) -> None: - with pytest.raises(SmithyException): + with pytest.raises(SmithyError): split_header(given) diff --git a/packages/smithy-json/src/smithy_json/_private/deserializers.py b/packages/smithy-json/src/smithy_json/_private/deserializers.py index 8134b3aa1..ac79f646e 100644 --- a/packages/smithy-json/src/smithy_json/_private/deserializers.py +++ b/packages/smithy-json/src/smithy_json/_private/deserializers.py @@ -11,7 +11,7 @@ from ijson.common import ObjectBuilder # type: ignore from smithy_core.deserializers import ShapeDeserializer from smithy_core.documents import Document -from smithy_core.exceptions import SmithyException +from smithy_core.exceptions import SmithyError from smithy_core.interfaces import BytesReader from smithy_core.schemas import Schema from smithy_core.shapes import ShapeID, ShapeType @@ -45,7 +45,7 @@ class JSONParseEvent(NamedTuple): value: JSONParseEventValue -class JSONTokenError(SmithyException): +class JSONTokenError(SmithyError): def __init__(self, expected: str, event: JSONParseEvent) -> None: super().__init__( f"Error parsing JSON. Expected token of type `{expected}` at path " From 1a70a94fef7b70f5e3982805ea2b09be6a737151 Mon Sep 17 00:00:00 2001 From: JordonPhillips Date: Tue, 29 Apr 2025 14:12:38 +0200 Subject: [PATCH 11/13] Fix typo in exceptions doc --- designs/exceptions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/designs/exceptions.md b/designs/exceptions.md index 059060877..19b1214c4 100644 --- a/designs/exceptions.md +++ b/designs/exceptions.md @@ -10,7 +10,7 @@ smithy-python clients will expose exceptions to customers. specific catch statement (that is, not just `except Exception`). * Every modeled exception raised by a service should be catchable with a single, specific catch statement. -* Exceptions should contain information about retryablility where relevant. +* Exceptions should contain information about retryability where relevant. ## Specification From a9f506337784d35476992459e7beb2a9dc8f4a1f Mon Sep 17 00:00:00 2001 From: JordonPhillips Date: Tue, 29 Apr 2025 16:17:19 +0200 Subject: [PATCH 12/13] Add retries design doc --- designs/exceptions.md | 7 +- designs/retries.md | 170 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 designs/retries.md diff --git a/designs/exceptions.md b/designs/exceptions.md index 19b1214c4..860b5f19f 100644 --- a/designs/exceptions.md +++ b/designs/exceptions.md @@ -37,7 +37,6 @@ retryability properties will be standardized as a `Protocol` that exceptions MAY implement. ```python -@dataclass(kw_only=True) @runtime_checkable class ErrorRetryInfo(Protocol): is_retry_safe: bool | None = None @@ -64,6 +63,8 @@ If an exception with `ErrorRetryInfo` is received while attempting to send a serialized request to the server, the contained information will be used to inform the next retry. +See the retry design for more details on how this information is used. + ### Service Errors Errors returned by the service MUST be a `CallError`. `CallError`s include a @@ -82,6 +83,10 @@ type Fault = Literal["client", "server"] | None If None, then there was not enough information to determine fault. """ +@runtime_checkable +class HasFault(Protocol): + fault: Fault + @dataclass(kw_only=True) class CallError(SmithyError, ErrorRetryInfo): diff --git a/designs/retries.md b/designs/retries.md new file mode 100644 index 000000000..62ec73bf5 --- /dev/null +++ b/designs/retries.md @@ -0,0 +1,170 @@ +# Retries + +Operation requests might fail for a number of reasons that are unrelated to the +input paramters, such as a transient network issue, or excessive load on the +service. This document describes how Smithy clients will automatically retry in +those cases, and how the retry system can be modified. + +## Specification + +Retry behavior will be determined by a `RetryStrategy`. Implementations of the +`RetryStrategy` will produce `RetryToken`s that carry metadata about the +invocation, notably the number of attempts that have occurred and the amount of +time that must pass before the next attempt. Passing state through tokens in +this way allows the `RetryStrategy` itself to be isolated from the state of an +individual request. + +```python +@dataclass(kw_only=True) +class RetryToken(Protocol): + retry_count: int + """Retry count is the total number of attempts minus the initial attempt.""" + + retry_delay: float + """Delay in seconds to wait before the retry attempt.""" + + +class RetryStrategy(Protocol): + backoff_strategy: RetryBackoffStrategy + """The strategy used by returned tokens to compute delay duration values.""" + + max_attempts: int + """Upper limit on total attempt count (initial attempt plus retries).""" + + def acquire_initial_retry_token( + self, *, token_scope: str | None = None + ) -> 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. + :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. + """ + ... + + def refresh_retry_token_for_retry( + self, *, token_to_renew: RetryToken, error: Exception + ) -> RetryToken: + """Replace an existing retry token from a failed attempt with a new token. + + :param token_to_renew: The token used for the previous failed attempt. + :param error: The error that triggered the need for a retry. + :raises RetryError: If no further retry attempts are allowed. + """ + ... + + def record_success(self, *, token: RetryToken) -> None: + """Return token after successful completion of an operation. + + :param token: The token used for the previous successful attempt. + """ + ... +``` + +A request using a `RetryStrategy` would look something like the following +example: + +```python +try: + retry_token = retry_strategy.acquire_initial_retry_token() +except RetryError: + transpoort_response = transport_client.send(serialized_request) + return self._deserialize(transport_response) + +while True: + await asyncio.sleep(retry_token.retry_delay) + try: + transpoort_response = transport_client.send(serialized_request) + response = self._deserialize(transport_response) + except Exception as e: + response = e + + if isinstance(response, Exception): + try: + retry_token = retry_strategy.refresh_retry_token_for_retry( + token_to_renew=retry_token, + error=e + ) + continue + except RetryError retry_error: + raise retry_error from e + + retry_strategy.record_success(token=retry_token) + return response +``` + +### Error Classification + +Different types of exceptions may require different amounts of delay or may not +be retryable at all. To facilitate passing important information around, +exceptions may implement the `ErrorRetryInfo` and/or `HasFault` protocols. These +are defined in the exceptions design, but are reproduced here for ease of +reading: + +```python +@runtime_checkable +class ErrorRetryInfo(Protocol): + """A protocol for errors that have retry information embedded.""" + + is_retry_safe: bool | None = None + """Whether the error is safe to retry. + + A value of True does not mean a retry will occur, but rather that a retry is allowed + to occur. + + A value of None indicates that there is not enough information available to + determine if a retry is safe. + """ + + retry_after: float | None = None + """The amount of time that should pass before a retry. + + Retry strategies MAY choose to wait longer. + """ + + is_throttling_error: bool = False + """Whether the error is a throttling error.""" + + +type Fault = Literal["client", "server"] | None +"""Whether the client or server is at fault. + +If None, then there was not enough information to determine fault. +""" + + +@runtime_checkable +class HasFault(Protocol): + fault: Fault +``` + +`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. + +### Backoff Strategy + +Each `RetryStrategy` has a configurable `RetryBackoffStrategy`. This is a +stateless class that computes the next backoff delay based solely on the number +of retry attempts. + +```python +class RetryBackoffStrategy(Protocol): + def compute_next_backoff_delay(self, retry_attempt: int) -> float: + ... +``` + +Backoff strategies can be as simple as waiting a number of seconds equal to the +number of retry attempts, but that initial delay would be unacceptably long. A +default backoff strategy called `ExponentialRetryBackoffStrategy` is available +that uses exponential backoff with configurable jitter. + +Having the backoff calculation be stateless and separate allows the +`BackoffStrategy` to handle any extra context that may have wider scope. For +example, a `BackoffStrategy` could use a token bucket to limit retries +client-wide so that the client can limit the amount of load it is placing on the +server. Decoupling this logic from the straightforward math of delay computation +allows both components to be evolved separately. From a30f0a96326ceb80c2fb40cd252f42982a01d1dd Mon Sep 17 00:00:00 2001 From: JordonPhillips Date: Wed, 30 Apr 2025 18:11:53 +0200 Subject: [PATCH 13/13] Reformat retries doc --- designs/exceptions.md | 4 ++- designs/retries.md | 66 ++++++++++++++++++++++--------------------- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/designs/exceptions.md b/designs/exceptions.md index 860b5f19f..e4b44c158 100644 --- a/designs/exceptions.md +++ b/designs/exceptions.md @@ -63,7 +63,9 @@ If an exception with `ErrorRetryInfo` is received while attempting to send a serialized request to the server, the contained information will be used to inform the next retry. -See the retry design for more details on how this information is used. +See the +[retry design](https://github.com/smithy-lang/smithy-python/blob/develop/designs/retries.md) +for more details on how this information is used. ### Service Errors diff --git a/designs/retries.md b/designs/retries.md index 62ec73bf5..e9ba90d09 100644 --- a/designs/retries.md +++ b/designs/retries.md @@ -63,38 +63,6 @@ class RetryStrategy(Protocol): ... ``` -A request using a `RetryStrategy` would look something like the following -example: - -```python -try: - retry_token = retry_strategy.acquire_initial_retry_token() -except RetryError: - transpoort_response = transport_client.send(serialized_request) - return self._deserialize(transport_response) - -while True: - await asyncio.sleep(retry_token.retry_delay) - try: - transpoort_response = transport_client.send(serialized_request) - response = self._deserialize(transport_response) - except Exception as e: - response = e - - if isinstance(response, Exception): - try: - retry_token = retry_strategy.refresh_retry_token_for_retry( - token_to_renew=retry_token, - error=e - ) - continue - except RetryError retry_error: - raise retry_error from e - - retry_strategy.record_success(token=retry_token) - return response -``` - ### Error Classification Different types of exceptions may require different amounts of delay or may not @@ -168,3 +136,37 @@ example, a `BackoffStrategy` could use a token bucket to limit retries client-wide so that the client can limit the amount of load it is placing on the server. Decoupling this logic from the straightforward math of delay computation allows both components to be evolved separately. + +## Example Usage + +A request using a `RetryStrategy` would look something like the following +example: + +```python +try: + retry_token = retry_strategy.acquire_initial_retry_token() +except RetryError: + transport_response = transport_client.send(serialized_request) + return self._deserialize(transport_response) + +while True: + await asyncio.sleep(retry_token.retry_delay) + try: + transport_response = transport_client.send(serialized_request) + response = self._deserialize(transport_response) + except Exception as e: + response = e + + if isinstance(response, Exception): + try: + retry_token = retry_strategy.refresh_retry_token_for_retry( + token_to_renew=retry_token, + error=e + ) + continue + except RetryError as retry_error: + raise retry_error from e + + retry_strategy.record_success(token=retry_token) + return response +```