Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "bugfix",
"description": "Fixed `InterceptorChain` discarding changes made by `modify_*` hooks. Each interceptor now receives the value returned by the interceptor before it, so an interceptor that returns its input unchanged no longer undoes earlier modifications. `modify_before_completion` also now calls each interceptor's `modify_before_completion` hook instead of `modify_before_attempt_completion`."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "bugfix",
"description": "Fixed the request pipeline ignoring the request returned by `modify_before_serialization`. The modified request is now serialized, used for endpoint resolution, and passed to later hooks if serialization fails. `InterceptorChain` now also raises a `TypeError` when a `modify_*` hook returns `None`, instead of passing `None` to the next interceptor."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "bugfix",
"description": "Fixed `InterceptorChain` skipping later interceptors when one raised in a `read_*` hook. Every interceptor's read hook now runs, then the last exception is raised and earlier ones are logged. `read_after_attempt` also now passes each exception to later interceptors as the `response`, as `read_after_execution` already did."
}
13 changes: 6 additions & 7 deletions packages/smithy-core/src/smithy_core/aio/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,10 +322,9 @@ async def _handle_execution[I: SerializeableShape, O: DeserializeableShape](
call: ClientCall[I, O],
request_future: Future[RequestContext[I, TRequest]] | None,
) -> OutputContext[I, O, TRequest | None, TResponse | None]:
interceptor = call.interceptor
input_context = InputContext(request=call.input, properties=call.context)
try:
interceptor = call.interceptor

input_context = InputContext(request=call.input, properties=call.context)
interceptor.read_before_execution(input_context)

input_context = replace(
Expand All @@ -338,7 +337,7 @@ async def _handle_execution[I: SerializeableShape, O: DeserializeableShape](

transport_request = self.protocol.serialize_request(
operation=call.operation,
input=call.input,
input=input_context.request,
endpoint=_UNRESOLVED,
context=input_context.properties,
)
Expand All @@ -353,11 +352,11 @@ async def _handle_execution[I: SerializeableShape, O: DeserializeableShape](
)
except Exception as e:
return OutputContext(
request=call.input,
request=input_context.request,
response=e,
transport_request=None,
transport_response=None,
properties=call.context,
properties=input_context.properties,
)

try:
Expand Down Expand Up @@ -449,7 +448,7 @@ async def _handle_attempt[I: SerializeableShape, O: DeserializeableShape](

endpoint_params = EndpointResolverParams(
operation=call.operation,
input=call.input,
input=request_context.request,
context=request_context.properties,
)
_LOGGER.debug("Calling endpoint resolver with params: %s", endpoint_params)
Expand Down
197 changes: 142 additions & 55 deletions packages/smithy-core/src/smithy_core/interceptors.py

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions packages/smithy-core/tests/unit/aio/_pipeline_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ async def close(self) -> None:
class StubProtocol:
def __init__(self) -> None:
self.serialize_request_calls = 0
self.serialized_inputs: list[Any] = []
self.set_service_endpoint_calls = 0
self.deserialize_response_calls = 0
self.create_event_publisher_calls = 0
Expand All @@ -166,6 +167,7 @@ def id(self) -> ShapeID:

def serialize_request(self, **kwargs: Any) -> StubRequest:
self.serialize_request_calls += 1
self.serialized_inputs.append(kwargs["input"])
return StubRequest()

def set_service_endpoint(self, *, request: Any, endpoint: Any) -> Any:
Expand Down Expand Up @@ -198,7 +200,11 @@ def __init__(self) -> None:


class StubEndpointResolver:
def __init__(self) -> None:
self.resolved_inputs: list[Any] = []

async def resolve_endpoint(self, params: EndpointResolverParams[Any]) -> Any:
self.resolved_inputs.append(params.input)
return StubEndpoint()


Expand Down
59 changes: 58 additions & 1 deletion packages/smithy-core/tests/unit/aio/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,24 @@
from smithy_core.aio.eventstream import DuplexEventStream, InputEventStream
from smithy_core.aio.interfaces import ClientTransport
from smithy_core.auth import AuthOption
from smithy_core.exceptions import CallError, UnsupportedTransportError
from smithy_core.exceptions import CallError, SmithyError, UnsupportedTransportError
from smithy_core.interceptors import (
InputContext,
Interceptor,
InterceptorChain,
OutputContext,
)
from smithy_core.response import EMPTY_RESPONSE_METADATA
from smithy_core.shapes import ShapeID
from smithy_core.types import TypedProperties

from ._pipeline_harness import (
DuplexTransport,
NonDuplexTransport,
StubEndpointResolver,
StubEvent,
StubEventReceiver,
StubInput,
StubOutput,
UndeclaredTransport,
client_call,
Expand Down Expand Up @@ -172,6 +180,55 @@ async def test_auth_option_signer_properties_override_scheme_defaults() -> None:
}


class _ReplaceInput(Interceptor[Any, Any, Any, Any]):
"""Replaces the input before serialization and records what later hooks see."""

def __init__(self, fail_after_modifying: bool = False) -> None:
self.replacement = StubInput()
self.fail_after_modifying = fail_after_modifying
self.completion_request: Any = None

def modify_before_serialization(self, context: InputContext[Any]) -> Any:
return self.replacement

def read_before_serialization(self, context: InputContext[Any]) -> None:
if self.fail_after_modifying:
raise RuntimeError("boom")

def modify_before_completion(
self, context: OutputContext[Any, Any, Any, Any]
) -> Any:
self.completion_request = context.request
return context.response


async def test_modified_input_is_serialized_and_used_to_resolve_endpoint() -> None:
harness = pipeline_harness(NonDuplexTransport())
interceptor = _ReplaceInput()
resolver = StubEndpointResolver()
call = replace(
client_call(),
interceptor=InterceptorChain([interceptor]),
endpoint_resolver=resolver,
)

await harness.pipeline(call)

assert harness.protocol.serialized_inputs == [interceptor.replacement]
assert resolver.resolved_inputs == [interceptor.replacement]


async def test_modified_input_reaches_completion_when_serialization_fails() -> None:
harness = pipeline_harness(NonDuplexTransport())
interceptor = _ReplaceInput(fail_after_modifying=True)
call = replace(client_call(), interceptor=InterceptorChain([interceptor]))

with pytest.raises(SmithyError):
await harness.pipeline(call)

assert interceptor.completion_request is interceptor.replacement


class _CountingTransport:
def __init__(self) -> None:
self.close_calls = 0
Expand Down
198 changes: 198 additions & 0 deletions packages/smithy-core/tests/unit/test_interceptors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
import logging
from typing import Any, cast
from unittest.mock import Mock

import pytest
from smithy_core.interceptors import (
InputContext,
Interceptor,
InterceptorChain,
OutputContext,
RequestContext,
ResponseContext,
)
from smithy_core.types import TypedProperties


def _context() -> OutputContext[Any, Any, Any, Any]:
# Each field holds its own name, so a hook that returns the wrong field shows up.
return OutputContext(
request=cast(Any, "request"),
transport_request="transport_request",
transport_response="transport_response",
response=cast(Any, "response"),
properties=TypedProperties(),
)


READ_HOOKS = [
"read_before_execution",
"read_before_serialization",
"read_after_serialization",
"read_before_attempt",
"read_before_signing",
"read_after_signing",
"read_before_transmit",
"read_after_transmit",
"read_before_deserialization",
"read_after_deserialization",
"read_after_attempt",
"read_after_execution",
]

# Each modify hook mapped to the context field it replaces.
MODIFY_HOOKS = {
"modify_before_serialization": "request",
"modify_before_retry_loop": "transport_request",
"modify_before_signing": "transport_request",
"modify_before_transmit": "transport_request",
"modify_before_deserialization": "transport_response",
"modify_before_attempt_completion": "response",
"modify_before_completion": "response",
}


class _Appender(Interceptor[Any, Any, Any, Any]):
"""Appends a suffix to the value each modify hook receives."""

def __init__(self, suffix: str) -> None:
self._suffix = suffix

def modify_before_serialization(self, context: InputContext[Any]) -> Any:
return context.request + self._suffix

def modify_before_retry_loop(self, context: RequestContext[Any, Any]) -> Any:
return context.transport_request + self._suffix

def modify_before_signing(self, context: RequestContext[Any, Any]) -> Any:
return context.transport_request + self._suffix

def modify_before_transmit(self, context: RequestContext[Any, Any]) -> Any:
return context.transport_request + self._suffix

def modify_before_deserialization(
self, context: ResponseContext[Any, Any, Any]
) -> Any:
return context.transport_response + self._suffix

def modify_before_attempt_completion(
self, context: OutputContext[Any, Any, Any, Any]
) -> Any:
return cast(str, context.response) + self._suffix

def modify_before_completion(
self, context: OutputContext[Any, Any, Any, Any]
) -> Any:
return cast(str, context.response) + self._suffix


@pytest.mark.parametrize("hook,field", MODIFY_HOOKS.items())
def test_chain_passes_each_modification_to_the_next_interceptor(
hook: str, field: str
) -> None:
# This also covers a default interceptor undoing earlier changes, since the base
# hooks return the context's value just as _Appender reads it.
chain = InterceptorChain([_Appender("a"), _Appender("b")])

assert getattr(chain, hook)(_context()) == f"{field}ab"


@pytest.mark.parametrize("hook", MODIFY_HOOKS)
def test_chain_reuses_the_context_when_nothing_changes(hook: str) -> None:
context = _context()
later = Mock(spec=Interceptor)

getattr(InterceptorChain([Interceptor(), later]), hook)(context)

assert getattr(later, hook).call_args.args[0] is context


@pytest.mark.parametrize("hook", READ_HOOKS + list(MODIFY_HOOKS))
def test_chain_calls_only_the_matching_hook(hook: str) -> None:
interceptors = [Mock(spec=Interceptor), Mock(spec=Interceptor)]

getattr(InterceptorChain(interceptors), hook)(_context())

for interceptor in interceptors:
assert [name for name, *_ in interceptor.method_calls] == [hook]


@pytest.mark.parametrize("hook", MODIFY_HOOKS)
def test_modify_hook_returning_none_raises(hook: str) -> None:
forgot_to_return = Mock(spec=Interceptor)
getattr(forgot_to_return, hook).return_value = None
after = Mock(spec=Interceptor)

with pytest.raises(TypeError, match=hook):
getattr(InterceptorChain([forgot_to_return, after]), hook)(_context())

assert not after.method_calls


def _raising(name: str, calls: list[str]) -> Any:
"""An interceptor whose every hook records its name and raises."""

def raise_error(context: Any) -> None:
calls.append(name)
raise RuntimeError(name)

interceptor = Mock()
for hook in READ_HOOKS + list(MODIFY_HOOKS):
getattr(interceptor, hook).side_effect = raise_error
return interceptor


@pytest.mark.parametrize("hook", READ_HOOKS)
def test_read_hook_runs_every_interceptor_and_raises_the_last_error(
hook: str, caplog: pytest.LogCaptureFixture
) -> None:
caplog.set_level(logging.DEBUG, logger="smithy_core.interceptors")
calls: list[str] = []
chain = InterceptorChain([_raising(name, calls) for name in ("1", "2", "3")])

with pytest.raises(RuntimeError, match="3"):
getattr(chain, hook)(_context())

assert calls == ["1", "2", "3"]
dropped = [str(record.exc_info[1]) for record in caplog.records if record.exc_info]
assert dropped == ["1", "2"]


@pytest.mark.parametrize("hook", READ_HOOKS)
def test_read_hook_raises_when_a_later_interceptor_succeeds(hook: str) -> None:
calls: list[str] = []
succeeds = Mock(spec=Interceptor)
chain = InterceptorChain([_raising("1", calls), succeeds])

with pytest.raises(RuntimeError, match="1"):
getattr(chain, hook)(_context())

assert [name for name, *_ in succeeds.method_calls] == [hook]


@pytest.mark.parametrize("hook", ["read_after_attempt", "read_after_execution"])
def test_completion_read_hook_passes_the_error_to_later_interceptors(
hook: str,
) -> None:
later = Mock(spec=Interceptor)
chain = InterceptorChain([_raising("1", []), later])

with pytest.raises(RuntimeError):
getattr(chain, hook)(_context())

response = getattr(later, hook).call_args.args[0].response
assert isinstance(response, RuntimeError)
assert str(response) == "1"


@pytest.mark.parametrize("hook", MODIFY_HOOKS)
def test_modify_hook_stops_at_the_first_error(hook: str) -> None:
calls: list[str] = []
chain = InterceptorChain([_raising(name, calls) for name in ("1", "2")])

with pytest.raises(RuntimeError, match="1"):
getattr(chain, hook)(_context())

assert calls == ["1"]
Loading