Client Orchestrator Upgrade Guidance #2887
jdisanti
announced in
Change Log
Replies: 1 comment
-
The documentation links pointing to awslabs.github.io appear to be restricted access only. The following links are open to the public: |
Beta Was this translation helpful? Give feedback.
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
-
Generated smithy-rs clients have undergone an architectural overhaul that replaces the tower
Service
trait (referred to as the “middleware” implementation) with a new orchestration system that will be referred to as the “orchestrator” for the rest of this post. Users that implement middleware via towerService
or any of ourMapRequest
layers will experience significant breaking changes that will require refactoring to resolve.This new implementation brings a much easier client creation API, and a less error-prone API for customizing the client that has more extension points and encourages best practices. It also brings smithy-rs generated clients closer to how generated clients work in other code generators (e.g.,
smithy-kotlin
,smithy-swift
, and others in the future) so that customizations written for one language’s Smithy clients can be easily ported to another language. In the long term, the overall high-level client architecture will be the same across the board for Smithy clients in all languages.At the highest level, the orchestrator can be described by its parts:
When a request is made, the config is augmented by the runtime plugins, and the runtime components for that request are established by the runtime plugins. The list of interceptors is retrieved from config, and then the orchestrator goes through a fixed request execution pipeline with various hook points for customization with interceptors and runtime components.
There isn’t a ton of documentation on how this all works under the hood yet, but we do have the following docs, as well as the docs of the
aws-smithy-runtime-api
andaws-smithy-runtime
crates, which cover the finer details.Middleware deprecation
The release of smithy-rs v0.56.0 makes the new orchestrator the default client implementation. The middleware implementation is still around and can be opted into if necessary, but this his highly discouraged as it will be completely removed and unsupported in the next minor (non-patch) smithy-rs release. If you find that your app is not behaving as expected after the upgrade, you have a few options:
v0.55.0
.Interceptors aren’t middleware
But they’re pretty close. Here are the key differences:
Interceptors are a generic extension point with the goal of supporting “anything someone should be able to do” as opposed to “anything anyone might want to do.” Interceptors allow users to “inject” logic into specific stages of the request/response lifecycle. These stages are known as “hooks”.
Hooks are either read-only (read) or read/write (modify). The current list of hooks is as follows:
A hook will always run all applicable interceptors, even if one fails. If one or more interceptors run by a hook fail, orchestrator execution will skip ahead. The hook that is skipped to depends on whether the retry loop was entered. The Modify Before Attempt Completion and Read After Attempt hooks will always run if an interceptor fails during the retry loop. The Modify Before Execution Completion and Read After Execution hooks will always run.
If you’re relying on custom middlewares, you’ll need to convert them to the interceptor pattern in order to upgrade the SDK. Let’s look at two of the middlewares the SDK team had to convert as an example.
What hook(s) should I implement for my interceptor?
While it’s possible for a single interceptor to implement all of the above hooks at once, implementing one is enough for most use-cases. The question, then, is “which one should I implement?” When making this decision, here are a few things to consider:
ConfigBag
, when is that value set?NOTE: The most common hook implemented by our internal interceptors is Modify Before Signing. The second most common hook is Modify Before Transmit.
What if my middleware follows redirects, or modifies/replaces the request body?
Middleware that follow redirects or send multiple HTTP requests also don't translate to interceptors.
Middleware that completely replace the HTTP request body will not translate to interceptors well. You can use an interceptor to
std::mem::swap
the HTTP body out with another one (andSdkBody
can wrap any arbitrary streaming body implementation), but unless you replace the body with an in-memory variant ofSdkBody
, you will lose the ability to retry. Note: you would have potentially lost the ability to retry in middleware also, depending on how the middleware was implemented.If you need to follow redirects or replace the HTTP body with one that is streaming as opposed to in-memory, you should do it as a custom
HttpConnector
instead of trying to do it in an interceptor.Setting interceptors
All necessary interceptors are set by default, but users may want to define and set their own. Interceptors can be added one-at-a-time or in a list:
The above method works by taking ownership of
self
. The two methods below take a mutable reference toself
.Example #1: Updating the recursion detection middleware
The recursion detection middleware is simple, but has a very important job. It detects when an SDK is being run in a Lambda and inserts a special tracking header so that services handling the request can avoid infinitely recursive lambda invocations.
Both the middleware and interceptors implementations rely on the same inner function:
The middleware is defined as a struct that implements the
MapRequest
helper trait:When converted to the new interceptor pattern, it looks like this:
Not so different, right? The most significant decision when converting simple middleware into interceptors is what hook(s) to implement. In this case, we chose the
modify_before_signing
hook, inserting the trace ID header right before signing the request. That way, the configured signer may choose to sign it.Note: all the types needed to implement an interceptor are re-exported in
crate::config
, andcrate::config::interceptors
.Example #2: Reconnecting when retrying 50X errors
By default, the AWS SDK for Rust won’t re-use a connection if it’s retrying a server error. This helps avoid sending another request to a server that’s struggling to respond, instead allowing another server to handle it. The way this is implemented is a bit complex but that’s what makes it a good example.
The
PoisonService
middleware is responsible for “poisoning” connections to servers that have returned 50X errors. When a connection is “poisoned” that signals to the underlying HTTP client that it shouldn’t be re-used for future requests.Because the
PoisonService
is a bit more complex, it implements thetower::Service
trait instead of theMapRequest
helper trait:When converted to an interceptor, we end up with this:
Summary
We hope this new method of extending the request/response lifecycle will be easy to use, but if you have trouble upgrading your middlewares, please file an issue and we’ll do our best to advise you.
Config hasn’t changed much
While the internals of SDK configuration have changed, we’ve put effort into ensuring the user experience remains the same.
SdkConfig
and service specific configs will have all the same fields they did before, as well as a few additions:push_interceptor
: Add aSharedInterceptor
that runs for specific hooks in the request/response lifecycle. Generally speaking, interceptors set to run on a hook are executedaccording to the pre-defined priorityin a non-deterministic order. However, the default set of interceptors provided by the SDK are guaranteed to run before interceptors set by the user, ensuring the user always has the final say.SharedInterceptor
is a wrapper type for structs implementingInterceptor
.time_source
: Set the time source for a client. The time source is used to provide the current time. By default, this will use the host machine’s system time. If you’re running on WASM, you’ll have to set your own time source depending on your specific runtime.Generic clients are simpler to use
If you’re generating a generic smithy client, client configuration and construction works just like it does for SDK clients, but with less defaults:
Configuring runtime plugins for generic clients
Generic clients can be configured in nearly all of the ways that SDK clients can. However, they also get access to a powerful tool that SDKs don’t have: Runtime plugins. Runtime plugins are similar to interceptors, except that they’re focused on building configuration for the request/response pipeline, instead of running inside it.
Runtime plugins can configure:
struct
s andenum
s that was inserted by other runtime plugins.)To better understand the power of runtime plugins, let’s look at a few examples.
Runtime plugins can bundle configuration and interceptors
To get started writing runtime plugins, you’ll need to create a struct (or enum) and have it implement the
RuntimePlugin
trait:Runtime plugins can replace the retry strategy and classifiers
It’s possible to replace default runtime components with your own versions. First, we’ll define a new retry strategy and a retry classifier. The classifier isn’t technically necessary, because it’s up to the retry strategy to call on it. We define one here
because it’s a good practice to separate the classification logic from the logic that decides how to respond to the classification result.
Now that we have a classifier and strategy defined, we create a runtime plugin that will set both of them:
Runtime plugins can insert shared state into the config bag
Runtime plugins are also useful for managing state that must be shared across different requests. The built-in SDK retry strategy is a great example of this, as it utilizes shared state for an client rate limiter and token bucket. Because the rate limiter and token bucket state is shared across requests, throttling responses received in one request/response lifecycle can affect how responses are retried in a different request/response lifecycle. This is only desirable when both requests are to the same service, so this state is “partitioned” based on the service’s name. In this way, request state is only shared on a per-service basis.
If you need to share state related to your client’s environmental constraints, then partitioning shared state may not be desirable at all. However, in both cases, sharing state is accomplished by passing around clones of a singleton wrapped in a
std::sync::Arc
and astd::sync::Mutex
:Once the shared state is in the config bag (and is a public type), any interceptor can access it.
CustomizableOperation::map_operation
has been removedA customizable operation allows a user to modify the current operation before it is dispatched.
map_request
andmutate_request
onCustomizableOperation
can be used in the orchestrator in the exactly the same way as before. However,map_operation
will no longer be supported since the orchestrator does not use theaws_smithy_http::operation::Operation
struct.The following examples demonstrate how to upgrade existing code that uses
map_operation
in order to achieve the same functionality in the orchestrator.Example #1: Inserting an item into a property bag
Previously
map_operation
allowed users to put an item intoaws_smithy_http::property_bag::PropertyBag
like so:This will no longer compile in the orchestrator because
Operation
will cease to exist. You can upgrade the above code as follows (or any use case that inserts an item into thePropertyBag
for that matter):Just remember that items to be stored using
store_put
need to implement the Storable trait (here is an example of how a type can be madeStorable
).Furthermore, the
Interceptor
trait defines several methods, and which one to use depends on the existing code to upgrade (the example above happens to usemodify_before_signing
). Refer back toWhat hook(s) should I implement for my interceptor?
for more details.Example #2: Extracting information from
aws_smithy_http::operation::Metadata
map_operation
used to allow users to extract fields in aMetadata
, which is a struct attached to an operation that identifies the API being called. It can be useful to obtain a service name and an operation name for a use case such as metrics collection. For instance,This piece of code can be upgraded as follows. You need to define your interceptor that extracts a
Metadata
out ofConfigBag
:Custom Auth Schemes
Previously, custom auth schemes would have been implemented by providing a middleware stage that resolves an identity and signs the request. Now there are
IdentityResolver
,AuthScheme
, andSigner
traits (in aws-smithy-runtime-api) that need to be implemented and registered as runtime components during client configuration.All the examples in this section will illustrate how to implement HTTP Basic Auth as a custom auth scheme.
Identity resolvers
Identity resolvers must implement the
IdentityResolver
trait, which has an asyncresolve_identity
function that returns anIdentity
. Any struct can be stored in anIdentity
, so you should create a struct for it that holds the information needed to implement your signer. Below is an example Login identity that could be used with Basic Auth:Important: Manually implement
Debug
with redaction so that you don’t unintentionally log secrets in your application.Where an identity resolves from varies from application to application. The below example illustrates resolving the above
Login
from environment variables:Important: As of this release, identities are not cached. If you need caching, you will have to implement it yourself. We will be adding identity resolver caching in a future release.
Signers
A signer is given a resolved identity and must sign a request with it. For basic auth, this may look as follows:
Auth schemes
The auth scheme is responsible for telling the client which identity resolver and signer to use. This implementation will typically be very simple:
Configuration
Piecing everything together requires implementing the
RuntimePlugin
trait, and registering this runtime plugin in the client config. Note that if you need different auth schemes per operation, and want them modeled in the Smithy model, then you will need to write a smithy-rs codegen plugin. Since the code generator’s plugin API isn’t even remotely stable right now, this won’t be covered here.First, implementing the
RuntimePlugin
trait:Finally, during client construction, this runtime plugin needs to be given to the client config:
At that point, everything should be wired up for your custom auth scheme.
One last thing: those generating their own client with
smithy-rs
may temporarily opt-out of these changesFor a limited time, we’re making it possible for users to upgrade their smithy-rs version but to keep using the old middleware-based client. You can do this by opening your
smithy-build.json
and setting"enableNewSmithyRuntime"
to"middleware"
(as opposed to its default,"orchestrator"
). The next time you generate a client, it’ll use the old middleware implementation. For example:Beta Was this translation helpful? Give feedback.
All reactions