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..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 @@ -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()); @@ -141,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", @@ -149,55 +147,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"); @@ -302,18 +258,24 @@ def _classify_error( } writer.addStdlibImport("typing", "Any"); writer.addStdlibImport("asyncio", "iscoroutine"); + writer.addImports("smithy_core.exceptions", Set.of("SmithyError", "CallError", "RetryError")); + 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 +283,29 @@ def _classify_error( request_future, response_future, ) except Exception as e: + # Make sure every exception that we throw is an instance of SmithyError so + # customers can reliably catch everything we throw. + if not isinstance(e, SmithyError): + wrapped = CallError(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 +314,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) @@ -413,12 +382,9 @@ 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: + except RetryError: raise output_context.response logger.debug( "Retry needed. Attempting request #%s in %.4f seconds.", @@ -455,24 +421,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 +835,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..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. 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 modeled client errors. * * @param settings The client settings, used to account for module configuration. * @return Returns the symbol for the client's error class. @@ -105,40 +102,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..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 @@ -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", "ModeledError"); 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(ModeledError): + ""\"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..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 @@ -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; @@ -130,31 +131,40 @@ 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.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): - ${5C|} + ${4C|} - code: ClassVar[str] = $3S - fault: ClassVar[Literal["client", "server"]] = $4S + fault: Literal["client", "server"] | None = $3S + ${?retryable} + is_retry_safe: bool | None = True + ${?throttling} + is_throttling_error: bool = True + ${/throttling} + ${/retryable} + + ${5C|} - message: str ${6C|} ${7C|} - ${8C|} - """, symbol.getName(), - apiError, - code, + baseError, fault, writer.consumer(w -> writeClassDocs(true)), writer.consumer(w -> writeProperties()), @@ -325,7 +335,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/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 b8841d26f..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 @@ -136,26 +136,30 @@ 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 canReadResponseBody = canReadResponseBody(operation, context.model()); delegator.useFileWriter(errorDispatcher.getDefinitionFile(), errorDispatcher.getNamespace(), writer -> { writer.pushState(new ErrorDispatcherSection(operation, errorShapeToCode, errorMessageCodeGenerator)); + 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) -> $4T: - ${6C|} + async def $1L(http_response: $2T, config: $3T) -> CallError: + ${4C|} match code.lower(): - ${7C|} + ${5C|} case _: - return $5T(f"{code}: {message}") + is_throttle = http_response.status == 429 + return CallError( + message=f"{code}: {message}", + fault="client" if http_response.status < 500 else "server", + is_throttling_error=is_throttle, + is_retry_safe=is_throttle or None, + ) """, 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..37813258a --- /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_throttling_error +fault diff --git a/designs/exceptions.md b/designs/exceptions.md new file mode 100644 index 000000000..e4b44c158 --- /dev/null +++ b/designs/exceptions.md @@ -0,0 +1,129 @@ +# 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 retryability where relevant. + +## Specification + +Every exception raised by a Smithy client MUST inherit from `SmithyError`. + +```python +class SmithyError(Exception): + """Base exception type for all exceptions raised by smithy-python.""" +``` + +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. `SerializationError`, 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 +@runtime_checkable +class ErrorRetryInfo(Protocol): + 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.""" +``` + +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](https://github.com/smithy-lang/smithy-python/blob/develop/designs/retries.md) +for more details on how this information is used. + +### Service Errors + +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 +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. +""" + +@runtime_checkable +class HasFault(Protocol): + fault: Fault + + +@dataclass(kw_only=True) +class CallError(SmithyError, ErrorRetryInfo): + fault: Fault = None + message: str = field(default="", kw_only=False) +``` + +#### 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 +`ServiceError` which itself inherits from the static `ModeledError`. + +```python +@dataclass(kw_only=True) +class ModeledError(CallError): + """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 ServiceError(ModeledError): + pass + + +@dataclass(kw_only=True) +class ThrottlingError(ServiceError): + fault: Fault = "server" + is_retry_safe: bool | None = True + is_throttling_error: bool = True +``` diff --git a/designs/retries.md b/designs/retries.md new file mode 100644 index 000000000..e9ba90d09 --- /dev/null +++ b/designs/retries.md @@ -0,0 +1,172 @@ +# 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. + """ + ... +``` + +### 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. + +## 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 +``` 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 0c07e30d3..7d320b76c 100644 --- a/packages/smithy-core/src/smithy_core/exceptions.py +++ b/packages/smithy-core/src/smithy_core/exceptions.py @@ -1,39 +1,96 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -class SmithyException(Exception): +from dataclasses import dataclass, field +from typing import Literal + + +class SmithyError(Exception): """Base exception type for all exceptions raised by smithy-python.""" -class SerializationException(Exception): +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 CallError(SmithyError): + """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. + + 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.""" + + def __post_init__(self): + super().__init__(self.message) + + +@dataclass(kw_only=True) +class ModeledError(CallError): + """Base exception to be used for modeled errors.""" + + fault: Fault = "client" + + +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/exceptions.py b/packages/smithy-core/src/smithy_core/interfaces/exceptions.py index 240a7469c..a299640f0 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 diff --git a/packages/smithy-core/src/smithy_core/interfaces/retries.py b/packages/smithy-core/src/smithy_core/interfaces/retries.py index e0af8a3cf..a5c9d428b 100644 --- a/packages/smithy-core/src/smithy_core/interfaces/retries.py +++ b/packages/smithy-core/src/smithy_core/interfaces/retries.py @@ -1,46 +1,32 @@ # 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 +from typing import Protocol, runtime_checkable -class RetryErrorType(Enum): - """Classification of errors based on desired retry behavior.""" +@runtime_checkable +class ErrorRetryInfo(Protocol): + """A protocol for errors that have retry information embedded.""" - TRANSIENT = 1 - """A connection level error such as a socket timeout, socket connect error, TLS - negotiation timeout.""" + is_retry_safe: bool | None = None + """Whether the error is safe to retry. - THROTTLING = 2 - """The server explicitly told the client to back off, for example with HTTP status - 429 or 503.""" + A value of True does not mean a retry will occur, but rather that a retry is allowed + to occur. - 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. + 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. -@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. + Retry strategies MAY choose to wait longer. """ + is_throttling_error: bool = False + """Whether the error is a throttling error.""" + class RetryBackoffStrategy(Protocol): """Stateless strategy for computing retry delays based on retry attempt account.""" @@ -84,12 +70,12 @@ 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. """ ... 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. @@ -97,14 +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_info: If no further retry is allowed, this information is used to - construct the exception. - - :raises SmithyRetryException: If no further retry attempts are allowed. + :param error: The error that triggered the need for a retry. + :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 32275bc38..f3c79798b 100644 --- a/packages/smithy-core/src/smithy_core/retries.py +++ b/packages/smithy-core/src/smithy_core/retries.py @@ -5,9 +5,7 @@ from dataclasses import dataclass from enum import Enum -from smithy_core.interfaces.retries import RetryErrorType - -from .exceptions import SmithyRetryException +from .exceptions import RetryError 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,22 +226,19 @@ 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. - - :raises SmithyRetryException: If no further retry attempts are allowed. + :param error: The error that triggered the need for a retry. + :raises RetryError: 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( + 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_info}") + 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 99acb877b..0b3c23be4 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 CallError, RetryError 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 = 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_info=error_info - ) - with pytest.raises(SmithyRetryException): - strategy.refresh_retry_token_for_retry( - token_to_renew=token, error_info=error_info + token_to_renew=token, error=error ) + with pytest.raises(RetryError): + 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 - ) + with pytest.raises(RetryError): + 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 = CallError(is_retry_safe=None) + token = strategy.acquire_initial_retry_token() + with pytest.raises(RetryError): + 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 = CallError(fault="client", is_retry_safe=False) + token = strategy.acquire_initial_retry_token() + 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 "