|
| 1 | +(client-guidance-interceptors)= |
| 2 | +# Interceptors |
| 3 | + |
| 4 | +An **interceptor** is a general-purpose extension point that allows code to |
| 5 | +observe or modify *specific* stages of a request execution: serialization, |
| 6 | +signing, transmission, deserialization, and so on. Interceptors are registered |
| 7 | +at client creation time or per operation invocation. |
| 8 | + |
| 9 | +The other sections of this guide describe specific extension points for known |
| 10 | +use cases: retries, endpoint resolution, authentication, and transport. Those |
| 11 | +interfaces are designed to handle tasks that every client implementation will |
| 12 | +take on and which may need to be replaced or extended. In addition to those |
| 13 | +specific extension points, it is recommended to provide interceptors as a more |
| 14 | +general-purpose mechanism that allows users to implement a broader array of |
| 15 | +extensions. |
| 16 | + |
| 17 | +## Hooks |
| 18 | + |
| 19 | +Interceptors inject logic through methods that are called at specific points in |
| 20 | +the execution pipeline. These methods are called **hooks**. |
| 21 | + |
| 22 | +Each hook provides either an **immutable** view of the current state or a |
| 23 | +**mutable** view that allows modifications. It is important to maintain this |
| 24 | +distinction, even in languages where immutability may not be enforceable. Hooks |
| 25 | +often come in pairs: the mutable hook is called first so that all interceptors |
| 26 | +can make their changes, then the immutable hook is called so that interceptors |
| 27 | +can see the finalized state after all mutations have been applied. |
| 28 | + |
| 29 | +Where possible, it is important to limit what can be mutated in a mutable hook |
| 30 | +to only the properties that are relevant to that hook. This makes each hook |
| 31 | +easier to reason about, and helps to ensure that a change in execution order |
| 32 | +doesn't result in different behavior. |
| 33 | + |
| 34 | +### Hook sequence |
| 35 | + |
| 36 | +The following is an ordered list of recommended hooks. It is also recommended to |
| 37 | +make the list of hooks modifiable, so that new hooks may be added later. |
| 38 | + |
| 39 | +:::{important} |
| 40 | + |
| 41 | +In the following list, an **execution** is one entire end-to-end invocation of |
| 42 | +an operation. An **attempt** is a single try within that execution. There may be |
| 43 | +multiple attempts if the request needs to be retried. |
| 44 | + |
| 45 | +The **transport request** and **transport response** represent the serialized |
| 46 | +requests and responses that are sent to and received from the service. For HTTP |
| 47 | +protocols, these are HTTP requests and HTTP responses. |
| 48 | +::: |
| 49 | + |
| 50 | +1. **readBeforeExecution** *(immutable)* — The first thing called during an |
| 51 | + execution. This inspects the client state and the unmodified inputs to the |
| 52 | + operation. |
| 53 | +2. **modifyBeforeSerialization** *(mutable)* — Modifies the input before it is |
| 54 | + serialized. |
| 55 | +3. **readBeforeSerialization** *(immutable)* — Called immediately before the |
| 56 | + input is serialized. |
| 57 | +4. **readAfterSerialization** *(immutable)* — Called immediately after the input |
| 58 | + is serialized. |
| 59 | +5. **modifyBeforeRetryLoop** *(mutable)* — Modifies the transport request before |
| 60 | + the retry loop begins. |
| 61 | +6. *(retry loop)* |
| 62 | + 1. **readBeforeAttempt** *(immutable)* — The first thing called inside the |
| 63 | + retry loop. |
| 64 | + 2. **modifyBeforeSigning** *(mutable)* — Can modify the transport request |
| 65 | + before signing. |
| 66 | + 3. **readBeforeSigning** *(immutable)* — Called immediately before signing. |
| 67 | + 4. **readAfterSigning** *(immutable)* — Called immediately after signing. |
| 68 | + 5. **modifyBeforeTransmit** *(mutable)* — Can modify the transport request |
| 69 | + before it is sent. |
| 70 | + 6. **readBeforeTransmit** *(immutable)* — Called immediately before the |
| 71 | + request is sent. |
| 72 | + 7. **readAfterTransmit** *(immutable)* — Called immediately after the |
| 73 | + transport response is received. |
| 74 | + 8. **modifyBeforeDeserialization** *(mutable)* — Can modify the transport |
| 75 | + response before deserialization. |
| 76 | + 9. **readBeforeDeserialization** *(immutable)* — Called immediately before |
| 77 | + deserialization. |
| 78 | + 10. **readAfterDeserialization** *(immutable)* — Called immediately after |
| 79 | + deserialization. |
| 80 | + 11. **modifyBeforeAttemptCompletion** *(mutable)* — Can modify the output or |
| 81 | + error before the attempt ends. |
| 82 | + 12. **readAfterAttempt** *(immutable)* — The last thing called inside the |
| 83 | + retry loop. |
| 84 | +7. **modifyBeforeCompletion** *(mutable)* — Can modify the output or error |
| 85 | + before the execution ends. |
| 86 | +8. **readAfterExecution** *(immutable)* — The last thing called during an |
| 87 | + execution. |
| 88 | + |
| 89 | +### Error behavior |
| 90 | + |
| 91 | +Errors raised in hooks are handled consistently. The behavior depends on where |
| 92 | +in the pipeline the hook is called: |
| 93 | + |
| 94 | +- **Hooks called once per execution** (`readBeforeExecution`, |
| 95 | + `readAfterExecution`): Errors are collected across all interceptors before any |
| 96 | + further action is taken. If multiple interceptors raise errors, the last error |
| 97 | + wins and earlier ones are logged and dropped. |
| 98 | + |
| 99 | +- **Most other hooks**: An error immediately jumps execution to |
| 100 | + `modifyBeforeAttemptCompletion` (if inside the retry loop) or |
| 101 | + `modifyBeforeCompletion` (if outside), with the error set as the result. |
| 102 | + |
| 103 | +- **`readAfterAttempt`**: Errors are collected the same way as |
| 104 | + `readBeforeExecution`. After all interceptors have been called, the |
| 105 | + [retry strategy](#client-guidance-retries) decides whether to retry or proceed |
| 106 | + to `modifyBeforeCompletion`. |
| 107 | + |
| 108 | +## Interfaces |
| 109 | + |
| 110 | +### Hook input types |
| 111 | + |
| 112 | +Each hook receives a typed input object that contains only the data available at |
| 113 | +that stage of the pipeline, along with a [context object](#typed-context). Using |
| 114 | +typed inputs prevents interceptors from accidentally accessing data that doesn't |
| 115 | +exist yet (for example, trying to read the transport response before a request |
| 116 | +has been sent). |
| 117 | + |
| 118 | +```java |
| 119 | +// Available from readBeforeExecution and modifyBeforeSerialization onward. |
| 120 | +// Always contains the operation input. |
| 121 | +public class InputHook<I, O> { |
| 122 | + public I input() { ... } |
| 123 | + public Context context() { ... } |
| 124 | +} |
| 125 | + |
| 126 | +// Available from readAfterSerialization and modifyBeforeRetryLoop onward. |
| 127 | +// Adds the protocol-specific request. |
| 128 | +public class RequestHook<I, O, RequestT> extends InputHook<I, O> { |
| 129 | + public RequestT request() { ... } |
| 130 | +} |
| 131 | + |
| 132 | +// Available from readAfterTransmit and modifyBeforeDeserialization onward. |
| 133 | +// Adds the protocol-specific response. |
| 134 | +public class ResponseHook<I, O, RequestT, ResponseT> extends RequestHook<I, O, RequestT> { |
| 135 | + public ResponseT response() { ... } |
| 136 | +} |
| 137 | + |
| 138 | +// Available from readAfterDeserialization and modifyBeforeAttemptCompletion onward. |
| 139 | +// Adds the deserialized output (may be null if the attempt failed). |
| 140 | +public class OutputHook<I, O, RequestT, ResponseT> extends ResponseHook<I, O, RequestT, ResponseT> { |
| 141 | + public O output() { ... } |
| 142 | +} |
| 143 | +``` |
| 144 | + |
| 145 | +The context object on each hook is the same instance for the entire execution, |
| 146 | +so data stored in it by one hook is available to all subsequent hooks. See the |
| 147 | +[context guide](#typed-context) for details. |
| 148 | + |
| 149 | +### Interceptor interface |
| 150 | + |
| 151 | +It is highly recommended to design the interceptor interface so that |
| 152 | +implementations only need to override the hooks they care about. In Java, this |
| 153 | +means providing default no-op implementations for every method. Clients in other |
| 154 | +languages may prefer to use abstract classes or similar features. |
| 155 | + |
| 156 | +Mutable hooks should always return the message, whether or not it was modified. |
| 157 | +If no modification is needed, they return the original value unchanged. |
| 158 | + |
| 159 | +```java |
| 160 | +public interface Interceptor { |
| 161 | + |
| 162 | + default void readBeforeExecution(InputHook<?, ?> hook) {} |
| 163 | + |
| 164 | + default <I> I modifyBeforeSerialization(InputHook<I, ?> hook) { |
| 165 | + return hook.input(); |
| 166 | + } |
| 167 | + |
| 168 | + default void readBeforeSerialization(InputHook<?, ?> hook) {} |
| 169 | + |
| 170 | + default void readAfterSerialization(RequestHook<?, ?, ?> hook) {} |
| 171 | + |
| 172 | + default <RequestT> RequestT modifyBeforeRetryLoop(RequestHook<?, ?, RequestT> hook) { |
| 173 | + return hook.request(); |
| 174 | + } |
| 175 | + |
| 176 | + default void readBeforeAttempt(RequestHook<?, ?, ?> hook) {} |
| 177 | + |
| 178 | + default <RequestT> RequestT modifyBeforeSigning(RequestHook<?, ?, RequestT> hook) { |
| 179 | + return hook.request(); |
| 180 | + } |
| 181 | + |
| 182 | + default void readBeforeSigning(RequestHook<?, ?, ?> hook) {} |
| 183 | + |
| 184 | + default void readAfterSigning(RequestHook<?, ?, ?> hook) {} |
| 185 | + |
| 186 | + default <RequestT> RequestT modifyBeforeTransmit(RequestHook<?, ?, RequestT> hook) { |
| 187 | + return hook.request(); |
| 188 | + } |
| 189 | + |
| 190 | + default void readBeforeTransmit(RequestHook<?, ?, ?> hook) {} |
| 191 | + |
| 192 | + default void readAfterTransmit(ResponseHook<?, ?, ?, ?> hook) {} |
| 193 | + |
| 194 | + default <ResponseT> ResponseT modifyBeforeDeserialization(ResponseHook<?, ?, ?, ResponseT> hook) { |
| 195 | + return hook.response(); |
| 196 | + } |
| 197 | + |
| 198 | + default void readBeforeDeserialization(ResponseHook<?, ?, ?, ?> hook) {} |
| 199 | + |
| 200 | + default void readAfterDeserialization(OutputHook<?, ?, ?, ?> hook, RuntimeException error) {} |
| 201 | + |
| 202 | + default <O> O modifyBeforeAttemptCompletion(OutputHook<?, O, ?, ?> hook, RuntimeException error) { |
| 203 | + return hook.forward(error); |
| 204 | + } |
| 205 | + |
| 206 | + default void readAfterAttempt(OutputHook<?, ?, ?, ?> hook, RuntimeException error) {} |
| 207 | + |
| 208 | + default <O> O modifyBeforeCompletion(OutputHook<?, O, ?, ?> hook, RuntimeException error) { |
| 209 | + return hook.forward(error); |
| 210 | + } |
| 211 | + |
| 212 | + default void readAfterExecution(OutputHook<?, ?, ?, ?> hook, RuntimeException error) {} |
| 213 | +} |
| 214 | +``` |
| 215 | + |
| 216 | +## Example |
| 217 | + |
| 218 | +The following interceptor adds a tracing header to HTTP requests when running |
| 219 | +inside AWS Lambda. It uses `modifyBeforeTransmit` because the header needs to be |
| 220 | +added to the transport request after signing. Adding it before signing would |
| 221 | +cause the signature to include the header. That would be fine, but this |
| 222 | +particular header is added after signing in practice. |
| 223 | + |
| 224 | +```java |
| 225 | +public class AddTraceHeader implements ClientInterceptor { |
| 226 | + private final String traceId; |
| 227 | + |
| 228 | + public AddTraceHeader(String traceId) { |
| 229 | + this.traceId = traceId; |
| 230 | + } |
| 231 | + |
| 232 | + @Override |
| 233 | + public <RequestT> RequestT modifyBeforeTransmit(RequestHook<?, ?, RequestT> hook) { |
| 234 | + return hook.mapRequest(HttpRequest.class, h -> { |
| 235 | + if (h.request().headers().hasHeader("x-amzn-trace-id")) { |
| 236 | + return h.request(); |
| 237 | + } |
| 238 | + return h.request().toBuilder() |
| 239 | + .withReplacedHeader("x-amzn-trace-id", List.of(traceId)) |
| 240 | + .build(); |
| 241 | + }); |
| 242 | + } |
| 243 | +} |
| 244 | +``` |
| 245 | + |
| 246 | +`mapRequest` is a convenience method on `RequestHook` that applies a mapping |
| 247 | +function only if the request is of the expected type. This keeps the interceptor |
| 248 | +from failing if it is used with a non-HTTP protocol. |
| 249 | + |
| 250 | +## Configuring interceptors |
| 251 | + |
| 252 | +Interceptors should be configurable for the whole client, in which case they |
| 253 | +apply to every operation invocation made by that client. Interceptors configured |
| 254 | +this way can determine which operation is being executed based on the input if |
| 255 | +they need to apply to only a subset of operations. |
| 256 | + |
| 257 | +```java |
| 258 | +MyServiceClient client = MyServiceClient.builder() |
| 259 | + .addInterceptor(new AddTraceHeader(traceId)) |
| 260 | + .build(); |
| 261 | +``` |
| 262 | + |
| 263 | +Interceptors should also be configurable for a single operation execution. This |
| 264 | +is particularly important for things like debugging or profiling specific parts |
| 265 | +of a code base. |
| 266 | + |
| 267 | +```java |
| 268 | +client.getObject(GetObjectInput.builder() |
| 269 | + .bucket("example") |
| 270 | + .key("my-object") |
| 271 | + .addInterceptor(new AddTraceHeader(traceId)) |
| 272 | + .build()); |
| 273 | +``` |
| 274 | + |
| 275 | +### Execution order |
| 276 | + |
| 277 | +When multiple interceptors are configured, they should be called in a |
| 278 | +deterministic order because the order they are called in can impact the |
| 279 | +execution of the operation. |
| 280 | + |
| 281 | +The recommended ordering is: |
| 282 | + |
| 283 | +1. Interceptors that are configured on the client by default. This includes |
| 284 | + interceptors that are added during code generation. |
| 285 | +2. Interceptors that are configured on the client which are not applied by |
| 286 | + default. |
| 287 | +3. Interceptors configured for a single operation execution. |
| 288 | + |
| 289 | +## Why interceptors instead of middleware? |
| 290 | + |
| 291 | +Middleware is a common pattern for building request pipelines, and it works well |
| 292 | +as an internal implementation strategy. As a public extension point, however, it |
| 293 | +has a significant drawback: middleware can modify control flow. A middleware |
| 294 | +component can wrap the next stage in its own retry loop, short-circuit the |
| 295 | +pipeline entirely, or call the next stage multiple times. This makes it |
| 296 | +impossible to reason about the behavior of the pipeline as a whole when |
| 297 | +third-party middleware is present. |
| 298 | + |
| 299 | +Interceptors deliberately cannot modify control flow. They can observe and |
| 300 | +modify messages, but the pipeline itself always executes in the same order. This |
| 301 | +makes the behavior of the client predictable and easier to reason about. It also |
| 302 | +makes it safe to add new behavior to the pipeline without risking unexpected |
| 303 | +interactions with existing interceptors. |
0 commit comments