feat: move close-body, logger, and service-metadata work out of the middleware stack - #683
Conversation
8aae7ed to
8566e94
Compare
b7f59f3 to
1234a40
Compare
|
|
||
| @Override | ||
| public List<RuntimeClientPlugin> getClientPlugins() { | ||
| // The logger is set on the context directly in invokeOperation |
There was a problem hiding this comment.
shouldn't this now have an OperationContextResolver instead? if someone overrides this integration the generated code that references options.Logger elsewhere will be broken
There was a problem hiding this comment.
Good catch, agreed. Done. ClientLogger now generates a setLoggerContext resolver and registers it via addOperationContextResolver, and I removed the hardcoded SetLogger line from the invokeOperation template. So overriding the integration removes the logger setup too, no dangling options.Logger reference.
| public abstract Writable generateDeserialize(); | ||
|
|
||
| private Writable generateHandleDeserialize() { | ||
| // Close body on success unless the output is a caller-owned event stream. |
There was a problem hiding this comment.
dont both of these serialize/deserialize middleware changes also need replicated in the schema-serde path? this is the legacy serde path
There was a problem hiding this comment.
Good call. This is the legacy path. serde2 relied on the same standalone close/content-length middlewares (registered for all services), which this change removes, so I've replicated the inline behavior in the serde2 path:
Serde2DeserializeResponseMiddleware:defer CloseResponseBodyafter deserialize, skipping caller-owned streams (IsOutputEventStream()or thesmithy.StreamingOutputinterface on the output).Serde2SerializeRequestMiddleware: compute content length after serialize, skipping event-stream inputs.
Streaming is detected at runtime here since the serde2 middlewares are per-service rather than per-operation, but the behavior matches the legacy paths.
| "github.com/aws/smithy-go/middleware" | ||
| ) | ||
|
|
||
| // DrainAndCloseResponseBody drains and closes the HTTP response body. It always |
There was a problem hiding this comment.
for something we're adding to the runtime like this we should really just explicitly parameterize it rather than have behavior flags that are documented as "pass x when y".
e.g. DrainAndClose(ctx, resp, err, isStreaming)
There was a problem hiding this comment.
Agreed, done. Switched to an explicit isStreaming param. It actually simplified further than your example: since we're dropping the success-path drain (the body's already consumed during deserialization), the success and error paths do the same thing, so the err param isn't needed either. It's now CloseResponseBody(ctx, resp, isStreaming).
| } | ||
|
|
||
| if opErr != nil { | ||
| // Consume the full body to prevent TCP connection resets on some platforms. |
There was a problem hiding this comment.
I know this is existing but I'm wondering if we really care to do this at all. In the success case we know we read the whole body to deserialize it so it's pointless. If at this point we did not read the whole response body it's either because
- it's a streaming payload and it's the caller's problem
- we had an actual error reading the response body, which means any further attempt to read it would probably also error, and also if this claim is true about connection reuse (idk if it is tbh) we probably want to throw that connection away anyway
There was a problem hiding this comment.
Agreed. Removed the drain entirely. Success path: the body's already fully read during deserialization, so there's nothing to drain. Error path: as you said, a failed read won't get better by re-reading, and we'd want to discard that connection anyway. So CloseResponseBody now just closes (unless it's a caller-owned stream) and never drains.
|
|
||
| // Close the body on every exit path in place of the standalone close middleware. | ||
| resp, _ := out.RawResponse.($response:P) | ||
| defer $drainClose:T(ctx, resp, $closeOnSuccess:L, err) |
There was a problem hiding this comment.
arguments passed to defer are evaluated/captured when defer is called, so if err gets set to something else later you're dropping its value
There was a problem hiding this comment.
Good catch. Rather than wrap it in a closure to capture the final err, the reworked function doesn't take err at all anymore (dropping the drain made the success/error paths identical, so there's nothing to branch on). The generated call is now defer CloseResponseBody(ctx, resp, isStreaming) with no err, so the early-capture problem goes away entirely.
There was a problem hiding this comment.
Following up on my previous reply: I walked this back. Dropping err was wrong for streaming outputs.
The concern is the error path for a caller-owned stream. If an operation whose output is a streaming payload (or event stream) fails during deserialization, isStreaming is true, so a no-err CloseResponseBody(ctx, resp, isStreaming) would skip the close and leak the body, since there is no successful
stream for the caller to consume and close. The standalone error-close middleware closed it in that case, so dropping err would have been a behavior change (the kitchensink test caught exactly this: streaming output + error path, body not closed).
So the function keeps err: CloseResponseBody(ctx, resp, isStreaming, opErr). It leaves the body open only when isStreaming && opErr == nil (a successful caller-owned stream); on error, or for a non-streaming response, it always closes. This preserves the original behavior on every path.
To your original point about defer capturing err too early: the generated call is a closure, defer func() { CloseResponseBody(ctx, resp, isStreaming, err) }(), so it reads the final err at return time rather than capturing it at the defer line. err is a named return value in the generated
deserializer, so the closure sees whatever the deserialize logic last set.
|
|
||
| private Writable generateHandleDeserialize() { | ||
| // Close body on success unless the output is a caller-owned event stream. | ||
| boolean closeOnSuccess = EventStreamIndex.of(ctx.getModel()).getOutputInfo(operation).isEmpty(); |
There was a problem hiding this comment.
pretty sure this should also be the case for @streaming blob? e.g. GetObject
There was a problem hiding this comment.
Good catch. And while doing it I noticed the three deserialize paths had drifted: HttpBinding checked both event streams and @streaming payloads, but HttpRpc and this serde path only checked event streams. I pulled the check into a shared ProtocolUtils.isCallerOwnedResponseStream(model, operation) (event stream OR @streaming payload) and pointed all three paths at it, so they can't drift again.
|
We are probably going to want some new kitchen sink tests for these things over in the SDK ( |
| }); | ||
| writer.openBlock("func $L(ctx $T, options Options, operation string) $T {", "}", | ||
| SET_LOGGER_CONTEXT_RESOLVER, contextSymbol, contextSymbol, () -> { | ||
| writer.write("_ = operation"); |
There was a problem hiding this comment.
Is there any other resolver func implementing this func(ctx, options, string) required by operationContextResolvers? Asked since the opId here is just dropped
My understanding is this opId is kept for forward-compatibility once any other operation ctx resolvers are added
There was a problem hiding this comment.
Yes. The operation argument is consumed by the paired aws-sdk-go-v2 PR (#3480), which contributes a resolveServiceMetadata resolver of this same signature:
func resolveServiceMetadata(ctx context.Context, options Options, operation string) context.Context {
ctx = awsmiddleware.SetServiceID(ctx, ServiceID)
if options.Region != "" {
ctx = awsmiddleware.SetRegion(ctx, options.Region)
}
ctx = awsmiddleware.SetOperationName(ctx, operation) // <- uses opID
if options.EndpointResolver != nil {
ctx = awsmiddleware.SetRequiresLegacyEndpoints(ctx, true)
}
return ctx
}This replaces the old per-operation RegisterServiceMetadata Initialize middleware. That middleware stamped the operation name into the request context, and downstream code reads it back via GetOperationName(ctx):
aws/retry/attempt_metrics.gosetsrpc.methodon the metrics from itaws/retry/middleware.gologsservice, operationon retriesfeature/s3/managerbranches onGetOperationName(ctx) == "CreateMultipartUpload"
So the opID has to reach the context somehow.
It now needs to be passed in because the old middleware was generated per operation, so each instance carried its own name hard-coded in the struct (OperationName: "ListQueues") and just read s.OperationName at runtime, no argument needed. Folding it into invokeOperation collapses that into a single shared
resolveServiceMetadata function used by every operation, which therefore can't carry any one operation's name. The only way it still knows which operation is running is the opID that invokeOperation already has, passed in via the hook: ctx = resolveServiceMetadata(ctx, options, opID).
setLoggerContext is the one in-tree (smithy-go) resolver, and the logger genuinely doesn't depend on the operation, so it drops the arg (_ = operation). The third parameter is part of the shared contract for resolvers that do need it, resolveServiceMetadata being the motivating case.
1311512 to
491d181
Compare
491d181 to
e968570
Compare
…dalone middleware Add DrainAndCloseResponseBody and emit a deferred call to it from the generated operation deserializer (HttpRpc, HttpBinding, and protocol/serde2 rpc2 paths), removing the need for a standalone close-response-body middleware on the stack. closeOnSuccess is decided at codegen time: false for caller-owned streams (streaming payloads and event-stream outputs), true otherwise. The existing Add*CloseResponseBodyMiddleware functions are retained for backwards compatibility.
The setLogger middleware only stashed options.Logger into the request context at the start of the Initialize step. That value is static for the whole operation, so it can be set directly in invokeOperation alongside the existing WithServiceID / WithOperationName calls, removing a per-request middleware from every operation's stack. ClientLogger no longer registers a middleware; it still contributes the Logger config field and the default-logger resolver. The public AddSetLoggerMiddleware function is unchanged (still available for external callers).
Adds a RuntimeClientPlugin extension point, operationContextResolvers: a set of symbols pointing to functions with the signature func(context.Context, Options, string) context.Context. invokeOperation emits ctx = resolver(ctx, options, opID) for each, after options are finalized and before the middleware stack is built. This lets a plugin set request-scoped context values directly in invokeOperation instead of via a per-request Initialize-step middleware, for values (like the ones RegisterServiceMetadata sets) that live in a keyspace the smithy-go base template can't reference. No generated code changes until a plugin uses it.
…iddleware The ComputeContentLength middleware only set req.ContentLength from the serialized body length at the Build step. That value is available as soon as the body is serialized, so the generated operation serializer now computes it inline via a new exported smithyhttp.ComputeRequestContentLength(req), removing a per-request middleware from the stack. - transport/http: extract the compute logic into ComputeRequestContentLength; ComputeContentLength / AddComputeContentLengthMiddleware are kept but marked Deprecated (public API), now delegating to the shared function. - codegen: emit the call at the end of the operation serializer in all three paths (HttpRpc, HttpBinding, protocol/serde2). Skipped for event-stream inputs, matching the previous middleware registration condition. The call runs before the request leaves the serialize step, so downstream consumers that read req.ContentLength (SigV4/v4a signer, checksum) still see the computed value.
- CloseResponseBody: drop the drain and the closeOnSuccess/err params, take isStreaming instead. The body is already consumed during deserialization, so draining is a no-op; success and error paths now just close (unless it is a caller-owned stream). This also removes the defer-captures-err-early bug, since the call no longer takes err. - Share the streaming detection via ProtocolUtils.isCallerOwnedResponseStream (event stream OR @streaming payload), so all three deserializer codegen paths (HttpRpc, HttpBinding, serde2) use one implementation instead of three hand-written copies that had drifted (HttpRpc/serde2 only checked event streams and missed streaming blobs like S3 GetObject).
Move the SetLogger call out of the invokeOperation base template and into an operation context resolver contributed by ClientLogger, matching how service metadata is set. ClientLogger now generates a setLoggerContext resolver and registers it via addOperationContextResolver, so overriding the integration removes the logger setup too instead of leaving a dangling options.Logger reference in the generated template.
The schema-serde (serde2) middlewares relied on the standalone close-response-body and content-length middlewares, which this change removes. Replicate the inline behavior in the serde2 path to match the legacy paths: - Serde2DeserializeResponseMiddleware: defer CloseResponseBody after the response is deserialized, skipping caller-owned streams (event stream via OperationSchema.IsOutputEventStream, or a @streaming payload detected via the smithy.StreamingOutput interface on the output). - Serde2SerializeRequestMiddleware: compute the request content length once the body is serialized, skipping event-stream inputs. Streaming is detected at runtime here (the serde2 middlewares are per-service, not per-operation) rather than at codegen time as in the legacy paths, but the resulting behavior matches.
A streaming/event-stream response body is caller-owned only on success. On error the body is a diagnostic error payload, not a caller-owned stream, so it must still be closed — the previous change skipped it whenever the output was streaming, regressing the old ErrorCloseResponseBody behavior and leaking the connection on failed streaming requests. CloseResponseBody now takes the operation error and only leaves the body open for a successful streaming response (isStreaming && opErr == nil). The generated deserializers defer it in a closure so it observes the final err rather than capturing it early.
Content length must be computed after all body-mutating middleware have run. Request compression is a Serialize-step middleware that replaces the body with its gzip-compressed form; computing content length inline in the operation serializer runs before compression, so a compressed request would advertise the uncompressed length. The standalone ComputeContentLength middleware runs in the Build step (after Serialize), which is the stable anchor that guarantees it sees the final body. Reverts the inlined ComputeRequestContentLength across the HttpRpc, HttpBinding, rpc2, and serde2 serializer generators, and removes the now-unused ComputeRequestContentLength runtime function. The close-body, logger, and service-metadata changes are unaffected; only content length returns to a middleware.
Moving response-body close into the operation deserializer regressed event stream operations: the inner deserializer closed the body even for event streams, whose body is owned by the event stream deserializer. On the error path of a bidirectional stream the write side may still be active, so closing the HTTP/2 body there deadlocks (observed as a hang in bedrockruntime InvokeModelWithBidirectionalStream_ResponseError). Skip emitting the close in the operation deserializer for event stream operations across all protocol generators (HTTP binding, HTTP RPC, protocol, serde2). Streaming payloads (e.g. S3 GetObject) are unaffected. This restores the prior behavior where event stream operations did not register the close-response-body middlewares.
e968570 to
52f5e60
Compare
| ) { | ||
| out, metadata, err := next.HandleDeserialize(ctx, input) | ||
| if err != nil { | ||
| if resp, ok := out.RawResponse.(*Response); ok && resp != nil && resp.Body != nil { |
There was a problem hiding this comment.
Is there any reason you're changing these deprecated implementations? can't they just stay as-is?
There was a problem hiding this comment.
You're right, will revert to the original implementation. This was from an earlier iteration where I was consolidating both paths before deciding to deprecate. Since they're deprecated now it doesn't matter either way, but no reason to touch them.
|
|
||
| writer.write("response, ok := out.RawResponse.($P)", responseType); | ||
| writer.openBlock("if !ok {", "}", () -> { | ||
| writer.openBlock("if response == nil {", "}", () -> { |
There was a problem hiding this comment.
i think it relates to the above
|
|
||
| // Close the response body after deserialization (a streaming payload is | ||
| // kept open on success). Event streams close their own body. | ||
| writer.write("response, _ := out.RawResponse.($P)", responseType); |
There was a problem hiding this comment.
you really need to keep the explicit type check here, I know that you have the nil guard in CloseResponseBody but you don't have to if you're doing the proper (response, ok -> check ok) like we do everywhere else
There was a problem hiding this comment.
basically the pattern should always be, for deserialize
out,md,err = next.HandleDeserialize
if err != nil {
return out,md,err
}
resp, ok := out.whatever.(smithyhttp.Response)
if !ok {
return out, md, error about wrong transport
}
proceed
There was a problem hiding this comment.
Acknowledged on all of these. Will update all three generators (HttpBinding, HttpRpc, serde2)
|
|
||
| // Close the response body after deserialization (a streaming payload is | ||
| // kept open on success). Event streams close their own body. | ||
| writer.write("response, _ := out.RawResponse.($P)", responseType); |
| out, metadata, err = next.HandleDeserialize(ctx, in) | ||
|
|
||
| // Close the response body once deserialization is done. | ||
| resp, _ := out.RawResponse.($response:P) |
Address review feedback: the generated operation deserializer middlewares now follow the canonical shape used elsewhere -- check next's err first, then a single `resp, ok := out.RawResponse.(...)` assertion with an explicit `!ok` transport-error return, instead of a `_`-discarded assertion plus a nil guard. The deferred CloseResponseBody is registered after the `!ok` check so it still observes the final err on the deserialize (status-code) error path. Applied across the HTTP binding, HTTP RPC, protocol, and serde2 generators. Also revert the deprecated errorCloseResponseBodyMiddleware and closeResponseBody implementations to their original form (they keep their io.Copy drain rather than delegating to CloseResponseBody); they are deprecated and unused, so there is no reason to change their behavior.
|
approve pending CI pass AND downstream SDK CI pass |
Issue #, if available:
N/A
Description of changes:
Reduce the number of per-request middlewares on every operation's stack by moving work that does not need to be a standalone middleware into codegen: into the generated operation deserializer or
invokeOperation. Each removed middleware saves at least 2 allocations per request (the struct plus its handler wrapper) on a known hot path.Three independent changes, all in codegen plus shared runtime helpers:
1. Close response body in the operation deserializer (Deserialize -1)
CloseResponseBody(ctx, resp, isStreaming, opErr), a shared function that closes the response body. It leaves the body open only for a successful response whose payload is a caller-owned stream (isStreamingtrue with a nilopErr); on error, or for a non-streaming response, it always closes the body (an error response body is diagnostic, not a caller-owned stream).CloseResponseBodycall at the top of each generated operation deserializer, across theHttpRpc,HttpBinding, andprotocol/serde2deserializer generators. The call is deferred in a closure (defer func() { CloseResponseBody(..., err) }()) so it observes the final operation error.HttpRpc/HttpBindinggenerators,isStreamingis decided at codegen time viaProtocolUtils.isCallerOwnedResponseStream(event stream, or a@streamingpayload). Forserde2, the per-service middleware cannot see the operation at codegen time, so it is decided at runtime:m.operationSchema.IsOutputEventStream()for event streams, or thesmithy.StreamingOutputinterface for a@streamingpayload output.HttpProtocolUtils.getCloseResponseClientPluginshelper is removed.AddCloseResponseBodyMiddlewareandAddErrorCloseResponseBodyMiddlewareare kept and marked// Deprecated(they now delegate toCloseResponseBody) so external callers are not broken.2. Operation context resolver codegen hook
RuntimeClientPluginextension point,operationContextResolvers: a set of symbols pointing to functions with the signaturefunc(context.Context, Options, string) context.Context.invokeOperationemitsctx = resolver(ctx, options, opID)for each, after options are finalized and before the middleware stack is built.invokeOperationinstead of via a per-request Initialize-step middleware, for values that live in a keyspace the smithy-go base template cannot reference.3. Set the logger via the context resolver hook (Initialize -1)
setLoggermiddleware only stashedoptions.Loggerinto the request context at the start of Initialize.ClientLoggernow generates asetLoggerContextresolver (return middleware.SetLogger(ctx, options.Logger)) and registers it through the operation context resolver hook above, so the logger is set ininvokeOperationinstead of by a standalone middleware.ClientLoggerno longer registers a middleware; it still contributes theLoggerconfig field and the default-logger resolver. The publicAddSetLoggerMiddlewarefunction is unchanged.