Repository: getmoto/moto
Pull request #6749 (2023-08-31 19:39:03)

Techdebt: Simplify ManagedBlockchain setup & tests
Simplify request parsing by delegating it to the `dispatch`-method
(FYI @dfangl - this already removes some of the `method_dispatch`-invocations introduced in #6746 )

Remove the custom `exceptionhandler`, as this is also handled by `dispatch`

Reduce some logic in the tests.

Configure boto3 to only retry requests once. Boto3 will automatically retry these requests up to 5 times, but we know that they fail, so there is no reason to wait for them to fail 5 times.

This PR reduces the test time for `managed blockchain` from **22** to **6** seconds


Patch:
diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md
index a6a66e7ce..55921b3cf 100644
--- a/IMPLEMENTATION_COVERAGE.md
+++ b/IMPLEMENTATION_COVERAGE.md
@@ -6437,6 +6437,14 @@
 - [ ] update_workteam
 </details>
 
+## sagemaker-runtime
+<details>
+<summary>50% implemented</summary>
+
+- [X] invoke_endpoint
+- [ ] invoke_endpoint_async
+</details>
+
 ## scheduler
 <details>
 <summary>100% implemented</summary>
@@ -7482,7 +7490,6 @@
 - sagemaker-featurestore-runtime
 - sagemaker-geospatial
 - sagemaker-metrics
-- sagemaker-runtime
 - savingsplans
 - schemas
 - securityhub
diff --git a/docs/docs/services/sagemaker-runtime.rst b/docs/docs/services/sagemaker-runtime.rst
new file mode 100644
index 000000000..eff4d9028
--- /dev/null
+++ b/docs/docs/services/sagemaker-runtime.rst
@@ -0,0 +1,66 @@
+.. _implementedservice_sagemaker-runtime:
+
+.. |start-h3| raw:: html
+
+    <h3>
+
+.. |end-h3| raw:: html
+
+    </h3>
+
+=================
+sagemaker-runtime
+=================
+
+.. autoclass:: moto.sagemakerruntime.models.SageMakerRuntimeBackend
+
+|start-h3| Example usage |end-h3|
+
+.. sourcecode:: python
+
+            @mock_sagemakerruntime
+            def test_sagemakerruntime_behaviour:
+                boto3.client("sagemaker-runtime")
+                ...
+
+
+
+|start-h3| Implemented features for this service |end-h3|
+
+- [X] invoke_endpoint
+  
+        This call will return static data by default.
+
+        You can use a dedicated API to override this, by configuring a queue of expected results.
+
+        A request to `get_query_results` will take the first result from that queue. Subsequent requests using the same details will return the same result. Other requests using a different QueryExecutionId will take the next result from the queue, or return static data if the queue is empty.
+
+        Configuring this queue by making an HTTP request to `/moto-api/static/sagemaker/endpoint-results`. An example invocation looks like this:
+
+        .. sourcecode:: python
+
+            expected_results = {
+                "account_id": "123456789012",  # This is the default - can be omitted
+                "region": "us-east-1",  # This is the default - can be omitted
+                "results": [
+                    {
+                         "Body": "first body",
+                         "ContentType": "text/xml",
+                         "InvokedProductionVariant": "prod",
+                         "CustomAttributes": "my_attr",
+                     },
+                    # other results as required
+                ],
+            }
+            requests.post(
+                "http://motoapi.amazonaws.com:5000/moto-api/static/sagemaker/endpoint-results",
+                json=expected_results,
+            )
+
+            client = boto3.client("sagemaker", region_name="us-east-1")
+            details = client.invoke_endpoint(EndpointName="asdf", Body="qwer")
+
+        
+
+- [ ] invoke_endpoint_async
+
diff --git a/moto/__init__.py b/moto/__init__.py
index 928ae28cf..cb1527a08 100644
--- a/moto/__init__.py
+++ b/moto/__init__.py
@@ -157,6 +157,9 @@ mock_route53resolver = lazy_load(
 mock_s3 = lazy_load(".s3", "mock_s3")
 mock_s3control = lazy_load(".s3control", "mock_s3control")
 mock_sagemaker = lazy_load(".sagemaker", "mock_sagemaker")
+mock_sagemakerruntime = lazy_load(
+    ".sagemakerruntime", "mock_sagemakerruntime", boto3_name="sagemaker-runtime"
+)
 mock_scheduler = lazy_load(".scheduler", "mock_scheduler")
 mock_sdb = lazy_load(".sdb", "mock_sdb")
 mock_secretsmanager = lazy_load(".secretsmanager", "mock_secretsmanager")
diff --git a/moto/backend_index.py b/moto/backend_index.py
index 129e7bd7a..fdc603b74 100644
--- a/moto/backend_index.py
+++ b/moto/backend_index.py
@@ -1,4 +1,4 @@
-# autogenerated by /Users/plussier/dev/github/moto/scripts/update_backend_index.py
+# autogenerated by /home/bblommers/Software/Code/bblommers/moto/scripts/update_backend_index.py
 import re
 
 backend_url_patterns = [
@@ -153,6 +153,10 @@ backend_url_patterns = [
         re.compile("https?://([0-9]+)\\.s3-control\\.(.+)\\.amazonaws\\.com"),
     ),
     ("sagemaker", re.compile("https?://api\\.sagemaker\\.(.+)\\.amazonaws.com")),
+    (
+        "sagemaker-runtime",
+        re.compile("https?://runtime\\.sagemaker\\.(.+)\\.amazonaws\\.com"),
+    ),
     ("scheduler", re.compile("https?://scheduler\\.(.+)\\.amazonaws\\.com")),
     ("sdb", re.compile("https?://sdb\\.(.+)\\.amazonaws\\.com")),
     ("secretsmanager", re.compile("https?://secretsmanager\\.(.+)\\.amazonaws\\.com")),
diff --git a/moto/core/models.py b/moto/core/models.py
index 9f2d2a623..a556423fb 100644
--- a/moto/core/models.py
+++ b/moto/core/models.py
@@ -392,7 +392,10 @@ class ServerModeMockAWS(BaseMockAWS):
             region = self._get_region(*args, **kwargs)
             if region:
                 if "config" in kwargs:
-                    kwargs["config"].__dict__["user_agent_extra"] += " region/" + region
+                    user_agent = kwargs["config"].__dict__.get("user_agent_extra") or ""
+                    kwargs["config"].__dict__[
+                        "user_agent_extra"
+                    ] = f"{user_agent} region/{region}"
                 else:
                     config = Config(user_agent_extra="region/" + region)
                     kwargs["config"] = config
diff --git a/moto/iam/policy_validation.py b/moto/iam/policy_validation.py
index eff39e3a4..cc2516ac0 100644
--- a/moto/iam/policy_validation.py
+++ b/moto/iam/policy_validation.py
@@ -56,11 +56,17 @@ VALID_CONDITION_PREFIXES = ["ForAnyValue:", "ForAllValues:"]
 
 VALID_CONDITION_POSTFIXES = ["IfExists"]
 
-SERVICE_TYPE_REGION_INFORMATION_ERROR_ASSOCIATIONS = {
-    "iam": "IAM resource {resource} cannot contain region information.",
-    "s3": "Resource {resource} can not contain region information.",
+SERVICE_TYPE_REGION_INFORMATION_ERROR_ASSOCIATIONS: Dict[str, Any] = {
+    "iam": {
+        "error_message": "IAM resource {resource} cannot contain region information."
+    },
+    "s3": {
+        "error_message": "Resource {resource} can not contain region information.",
+        "valid_starting_values": ["accesspoint/"],
+    },
 }
 
+
 VALID_RESOURCE_PATH_STARTING_VALUES: Dict[str, Any] = {
     "iam": {
         "values": [
@@ -375,20 +381,34 @@ class BaseIAMPolicyValidator:
             resource_partitions = resource_partitions[2].partition(":")
 
             service = resource_partitions[0]
+            region = resource_partitions[2]
+            resource_partitions = resource_partitions[2].partition(":")
+
+            resource_partitions = resource_partitions[2].partition(":")
+            resource_id = resource_partitions[2]
 
             if (
                 service in SERVICE_TYPE_REGION_INFORMATION_ERROR_ASSOCIATIONS.keys()
-                and not resource_partitions[2].startswith(":")
+                and not region.startswith(":")
             ):
-                self._resource_error = (
-                    SERVICE_TYPE_REGION_INFORMATION_ERROR_ASSOCIATIONS[service].format(
-                        resource=resource
-                    )
-                )
-                return
+                valid_start = False
 
-            resource_partitions = resource_partitions[2].partition(":")
-            resource_partitions = resource_partitions[2].partition(":")
+                for (
+                    valid_starting_value
+                ) in SERVICE_TYPE_REGION_INFORMATION_ERROR_ASSOCIATIONS[service].get(
+                    "valid_starting_values", []
+                ):
+                    if resource_id.startswith(valid_starting_value):
+                        valid_start = True
+                        break
+
+                if not valid_start:
+                    self._resource_error = (
+                        SERVICE_TYPE_REGION_INFORMATION_ERROR_ASSOCIATIONS[service][
+                            "error_message"
+                        ].format(resource=resource)
+                    )
+                    return
 
             if service in VALID_RESOURCE_PATH_STARTING_VALUES.keys():
                 valid_start = False
diff --git a/moto/managedblockchain/exceptions.py b/moto/managedblockchain/exceptions.py
index 494080c76..f64915b6d 100644
--- a/moto/managedblockchain/exceptions.py
+++ b/moto/managedblockchain/exceptions.py
@@ -1,21 +1,6 @@
 import json
-from moto.core.common_types import TYPE_RESPONSE
 from moto.core.exceptions import JsonRESTError
-from functools import wraps
-from typing import Any, Callable, List, Tuple
-
-
-def exception_handler(
-    f: Callable[[Any, Any, str, Any], TYPE_RESPONSE]
-) -> Callable[[Any, Any, str, Any], TYPE_RESPONSE]:
-    @wraps(f)
-    def _wrapper(*args: Any, **kwargs: Any) -> TYPE_RESPONSE:  # type: ignore[misc]
-        try:
-            return f(*args, **kwargs)
-        except ManagedBlockchainClientError as err:
-            return err.code, err.get_headers(), err.description  # type: ignore
-
-    return _wrapper
+from typing import Any, List, Tuple
 
 
 class ManagedBlockchainClientError(JsonRESTError):
diff --git a/moto/managedblockchain/responses.py b/moto/managedblockchain/responses.py
index faec856a6..8912f725f 100644
--- a/moto/managedblockchain/responses.py
+++ b/moto/managedblockchain/responses.py
@@ -1,10 +1,6 @@
 import json
-from typing import Any, Dict, Optional
-from urllib.parse import parse_qs
 
-from moto.core.common_types import TYPE_RESPONSE
 from moto.core.responses import BaseResponse
-from .exceptions import exception_handler
 from .models import managedblockchain_backends, ManagedBlockchainBackend
 from .utils import (
     networkid_from_managedblockchain_url,
@@ -23,39 +19,20 @@ class ManagedBlockchainResponse(BaseResponse):
     def backend(self) -> ManagedBlockchainBackend:
         return managedblockchain_backends[self.current_account][self.region]
 
-    @exception_handler
-    def network_response(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[misc]
-        self.setup_class(request, full_url, headers)
-        return self._network_response(request, headers)
+    def list_networks(self) -> str:
+        networks = self.backend.list_networks()
+        return json.dumps({"Networks": [network.to_dict() for network in networks]})
 
-    def _network_response(self, request: Any, headers: Any) -> TYPE_RESPONSE:  # type: ignore[return]
-        method = request.method
-        if method == "GET":
-            return self._all_networks_response(headers)
-        elif method == "POST":
-            json_body = json.loads(self.body)
-            return self._network_response_post(json_body, headers)
-
-    def _all_networks_response(self, headers: Any) -> TYPE_RESPONSE:
-        mbcnetworks = self.backend.list_networks()
-        response = json.dumps(
-            {"Networks": [mbcnetwork.to_dict() for mbcnetwork in mbcnetworks]}
-        )
-        headers["content-type"] = "application/json"
-        return 200, headers, response
-
-    def _network_response_post(
-        self, json_body: Dict[str, Any], headers: Any
-    ) -> TYPE_RESPONSE:
-        name = json_body["Name"]
-        framework = json_body["Framework"]
-        frameworkversion = json_body["FrameworkVersion"]
-        frameworkconfiguration = json_body["FrameworkConfiguration"]
-        voting_policy = json_body["VotingPolicy"]
-        member_configuration = json_body["MemberConfiguration"]
+    def create_network(self) -> str:
+        name = self._get_param("Name")
+        framework = self._get_param("Framework")
+        frameworkversion = self._get_param("FrameworkVersion")
+        frameworkconfiguration = self._get_param("FrameworkConfiguration")
+        voting_policy = self._get_param("VotingPolicy")
+        member_configuration = self._get_param("MemberConfiguration")
 
         # Optional
-        description = json_body.get("Description", None)
+        description = self._get_param("Description", None)
 
         response = self.backend.create_network(
             name,
@@ -66,257 +43,110 @@ class ManagedBlockchainResponse(BaseResponse):
             member_configuration,
             description,
         )
-        return 200, headers, json.dumps(response)
-
-    @exception_handler
-    def networkid_response(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[misc]
-        self.setup_class(request, full_url, headers)
-        return self._networkid_response(request, full_url, headers)
+        return json.dumps(response)
 
-    def _networkid_response(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[return]
-        method = request.method
-
-        if method == "GET":
-            network_id = networkid_from_managedblockchain_url(full_url)
-            return self._networkid_response_get(network_id, headers)
-
-    def _networkid_response_get(self, network_id: str, headers: Any) -> TYPE_RESPONSE:
+    def get_network(self) -> str:
+        network_id = networkid_from_managedblockchain_url(self.path)
         mbcnetwork = self.backend.get_network(network_id)
-        response = json.dumps({"Network": mbcnetwork.get_format()})
-        headers["content-type"] = "application/json"
-        return 200, headers, response
-
-    @exception_handler
-    def proposal_response(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[misc]
-        self.setup_class(request, full_url, headers)
-        return self._proposal_response(request, full_url, headers)
+        return json.dumps({"Network": mbcnetwork.get_format()})
 
-    def _proposal_response(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[return]
-        method = request.method
-        network_id = networkid_from_managedblockchain_url(full_url)
-        if method == "GET":
-            return self._all_proposals_response(network_id, headers)
-        elif method == "POST":
-            json_body = json.loads(self.body)
-            return self._proposal_response_post(network_id, json_body, headers)
-
-    def _all_proposals_response(self, network_id: str, headers: Any) -> TYPE_RESPONSE:
+    def list_proposals(self) -> str:
+        network_id = networkid_from_managedblockchain_url(self.path)
         proposals = self.backend.list_proposals(network_id)
-        response = json.dumps(
-            {"Proposals": [proposal.to_dict() for proposal in proposals]}
-        )
-        headers["content-type"] = "application/json"
-        return 200, headers, response
+        return json.dumps({"Proposals": [proposal.to_dict() for proposal in proposals]})
 
-    def _proposal_response_post(
-        self, network_id: str, json_body: Dict[str, Any], headers: Any
-    ) -> TYPE_RESPONSE:
-        memberid = json_body["MemberId"]
-        actions = json_body["Actions"]
+    def create_proposal(self) -> str:
+        network_id = networkid_from_managedblockchain_url(self.path)
+        memberid = self._get_param("MemberId")
+        actions = self._get_param("Actions")
 
         # Optional
-        description = json_body.get("Description", None)
+        description = self._get_param("Description", None)
 
         response = self.backend.create_proposal(
             network_id, memberid, actions, description
         )
-        return 200, headers, json.dumps(response)
-
-    @exception_handler
-    def proposalid_response(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[misc]
-        self.setup_class(request, full_url, headers)
-        return self._proposalid_response(request, full_url, headers)
+        return json.dumps(response)
 
-    def _proposalid_response(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[return]
-        method = request.method
-        network_id = networkid_from_managedblockchain_url(full_url)
-        if method == "GET":
-            proposal_id = proposalid_from_managedblockchain_url(full_url)
-            return self._proposalid_response_get(network_id, proposal_id, headers)
-
-    def _proposalid_response_get(
-        self, network_id: str, proposal_id: str, headers: Any
-    ) -> TYPE_RESPONSE:
+    def get_proposal(self) -> str:
+        network_id = networkid_from_managedblockchain_url(self.path)
+        proposal_id = proposalid_from_managedblockchain_url(self.path)
         proposal = self.backend.get_proposal(network_id, proposal_id)
-        response = json.dumps({"Proposal": proposal.get_format()})
-        headers["content-type"] = "application/json"
-        return 200, headers, response
-
-    @exception_handler
-    def proposal_votes_response(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[misc]
-        self.setup_class(request, full_url, headers)
-        return self._proposal_votes_response(request, full_url, headers)
-
-    def _proposal_votes_response(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[return]
-        method = request.method
-        network_id = networkid_from_managedblockchain_url(full_url)
-        proposal_id = proposalid_from_managedblockchain_url(full_url)
-        if method == "GET":
-            return self._all_proposal_votes_response(network_id, proposal_id, headers)
-        elif method == "POST":
-            json_body = json.loads(self.body)
-            return self._proposal_votes_response_post(
-                network_id, proposal_id, json_body, headers
-            )
+        return json.dumps({"Proposal": proposal.get_format()})
 
-    def _all_proposal_votes_response(
-        self, network_id: str, proposal_id: str, headers: Any
-    ) -> TYPE_RESPONSE:
+    def list_proposal_votes(self) -> str:
+        network_id = networkid_from_managedblockchain_url(self.path)
+        proposal_id = proposalid_from_managedblockchain_url(self.path)
         proposalvotes = self.backend.list_proposal_votes(network_id, proposal_id)
-        response = json.dumps({"ProposalVotes": proposalvotes})
-        headers["content-type"] = "application/json"
-        return 200, headers, response
+        return json.dumps({"ProposalVotes": proposalvotes})
 
-    def _proposal_votes_response_post(
-        self, network_id: str, proposal_id: str, json_body: Dict[str, Any], headers: Any
-    ) -> TYPE_RESPONSE:
-        votermemberid = json_body["VoterMemberId"]
-        vote = json_body["Vote"]
+    def vote_on_proposal(self) -> str:
+        network_id = networkid_from_managedblockchain_url(self.path)
+        proposal_id = proposalid_from_managedblockchain_url(self.path)
+        votermemberid = self._get_param("VoterMemberId")
+        vote = self._get_param("Vote")
 
         self.backend.vote_on_proposal(network_id, proposal_id, votermemberid, vote)
-        return 200, headers, ""
+        return ""
 
-    @exception_handler
-    def invitation_response(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[misc]
-        self.setup_class(request, full_url, headers)
-        return self._invitation_response(request, headers)
-
-    def _invitation_response(self, request: Any, headers: Any) -> TYPE_RESPONSE:  # type: ignore[return]
-        method = request.method
-        if method == "GET":
-            return self._all_invitation_response(headers)
-
-    def _all_invitation_response(self, headers: Any) -> TYPE_RESPONSE:
+    def list_invitations(self) -> str:
         invitations = self.backend.list_invitations()
-        response = json.dumps(
+        return json.dumps(
             {"Invitations": [invitation.to_dict() for invitation in invitations]}
         )
-        headers["content-type"] = "application/json"
-        return 200, headers, response
-
-    @exception_handler
-    def invitationid_response(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[misc]
-        self.setup_class(request, full_url, headers)
-        return self._invitationid_response(request, full_url, headers)
-
-    def _invitationid_response(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[return]
-        method = request.method
-        if method == "DELETE":
-            invitation_id = invitationid_from_managedblockchain_url(full_url)
-            return self._invitationid_response_delete(invitation_id, headers)
 
-    def _invitationid_response_delete(
-        self, invitation_id: str, headers: Any
-    ) -> TYPE_RESPONSE:
+    def reject_invitation(self) -> str:
+        invitation_id = invitationid_from_managedblockchain_url(self.path)
         self.backend.reject_invitation(invitation_id)
-        headers["content-type"] = "application/json"
-        return 200, headers, ""
+        return ""
 
-    @exception_handler
-    def member_response(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[misc]
-        self.setup_class(request, full_url, headers)
-        return self._member_response(request, full_url, headers)
-
-    def _member_response(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[return]
-        method = request.method
-        network_id = networkid_from_managedblockchain_url(full_url)
-        if method == "GET":
-            return self._all_members_response(network_id, headers)
-        elif method == "POST":
-            json_body = json.loads(self.body)
-            return self._member_response_post(network_id, json_body, headers)
-
-    def _all_members_response(self, network_id: str, headers: Any) -> TYPE_RESPONSE:
+    def list_members(self) -> str:
+        network_id = networkid_from_managedblockchain_url(self.path)
         members = self.backend.list_members(network_id)
-        response = json.dumps({"Members": [member.to_dict() for member in members]})
-        headers["content-type"] = "application/json"
-        return 200, headers, response
+        return json.dumps({"Members": [member.to_dict() for member in members]})
 
-    def _member_response_post(
-        self, network_id: str, json_body: Dict[str, Any], headers: Any
-    ) -> TYPE_RESPONSE:
-        invitationid = json_body["InvitationId"]
-        member_configuration = json_body["MemberConfiguration"]
+    def create_member(self) -> str:
+        network_id = networkid_from_managedblockchain_url(self.path)
+        invitationid = self._get_param("InvitationId")
+        member_configuration = self._get_param("MemberConfiguration")
 
         response = self.backend.create_member(
             invitationid, network_id, member_configuration
         )
-        return 200, headers, json.dumps(response)
-
-    @exception_handler
-    def memberid_response(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[misc]
-        self.setup_class(request, full_url, headers)
-        return self._memberid_response(request, full_url, headers)
+        return json.dumps(response)
 
-    def _memberid_response(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[return]
-        method = request.method
-        network_id = networkid_from_managedblockchain_url(full_url)
-        member_id = memberid_from_managedblockchain_request(full_url, self.body)
-        if method == "GET":
-            return self._memberid_response_get(network_id, member_id, headers)
-        elif method == "PATCH":
-            json_body = json.loads(self.body)
-            return self._memberid_response_patch(
-                network_id, member_id, json_body, headers
-            )
-        elif method == "DELETE":
-            return self._memberid_response_delete(network_id, member_id, headers)
-
-    def _memberid_response_get(
-        self, network_id: str, member_id: str, headers: Dict[str, Any]
-    ) -> TYPE_RESPONSE:
+    def get_member(self) -> str:
+        network_id = networkid_from_managedblockchain_url(self.path)
+        member_id = memberid_from_managedblockchain_request(self.uri, self.body)
         member = self.backend.get_member(network_id, member_id)
-        response = json.dumps({"Member": member.get_format()})
-        headers["content-type"] = "application/json"
-        return 200, headers, response
+        return json.dumps({"Member": member.get_format()})
 
-    def _memberid_response_patch(
-        self, network_id: str, member_id: str, json_body: Dict[str, Any], headers: Any
-    ) -> TYPE_RESPONSE:
-        logpublishingconfiguration = json_body["LogPublishingConfiguration"]
+    def update_member(self) -> str:
+        network_id = networkid_from_managedblockchain_url(self.path)
+        member_id = memberid_from_managedblockchain_request(self.uri, self.body)
+        logpublishingconfiguration = self._get_param("LogPublishingConfiguration")
         self.backend.update_member(network_id, member_id, logpublishingconfiguration)
-        return 200, headers, ""
+        return ""
 
-    def _memberid_response_delete(
-        self, network_id: str, member_id: str, headers: Any
-    ) -> TYPE_RESPONSE:
+    def delete_member(self) -> str:
+        network_id = networkid_from_managedblockchain_url(self.path)
+        member_id = memberid_from_managedblockchain_request(self.uri, self.body)
         self.backend.delete_member(network_id, member_id)
-        headers["content-type"] = "application/json"
-        return 200, headers, ""
-
-    @exception_handler
-    def node_response(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[misc]
-        self.setup_class(request, full_url, headers)
-        return self._node_response(request, full_url, headers)
-
-    def _node_response(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[return]
-        method = request.method
-        querystring = parse_qs(self.parsed_url.query, keep_blank_values=True)
-        network_id = networkid_from_managedblockchain_url(full_url)
-        member_id = memberid_from_managedblockchain_request(full_url, self.body)
-        if method == "GET":
-            status = None
-            if "status" in querystring:
-                status = querystring["status"][0]
-            return self._all_nodes_response(network_id, member_id, status, headers)
-        elif method == "POST":
-            json_body = json.loads(self.body)
-            return self._node_response_post(network_id, member_id, json_body, headers)
+        return ""
 
-    def _all_nodes_response(
-        self, network_id: str, member_id: str, status: Optional[str], headers: Any
-    ) -> TYPE_RESPONSE:
+    def list_nodes(self) -> str:
+        network_id = networkid_from_managedblockchain_url(self.path)
+        member_id = memberid_from_managedblockchain_request(self.uri, self.body)
+        status = self._get_param("status")
         nodes = self.backend.list_nodes(network_id, member_id, status)
-        response = json.dumps({"Nodes": [node.to_dict() for node in nodes]})
-        headers["content-type"] = "application/json"
-        return 200, headers, response
-
-    def _node_response_post(
-        self, network_id: str, member_id: str, json_body: Dict[str, Any], headers: Any
-    ) -> TYPE_RESPONSE:
-        instancetype = json_body["NodeConfiguration"]["InstanceType"]
-        availabilityzone = json_body["NodeConfiguration"]["AvailabilityZone"]
-        logpublishingconfiguration = json_body["NodeConfiguration"][
+        return json.dumps({"Nodes": [node.to_dict() for node in nodes]})
+
+    def create_node(self) -> str:
+        network_id = networkid_from_managedblockchain_url(self.path)
+        member_id = memberid_from_managedblockchain_request(self.uri, self.body)
+        instancetype = self._get_param("NodeConfiguration")["InstanceType"]
+        availabilityzone = self._get_param("NodeConfiguration")["AvailabilityZone"]
+        logpublishingconfiguration = self._get_param("NodeConfiguration")[
             "LogPublishingConfiguration"
         ]
 
@@ -327,53 +157,27 @@ class ManagedBlockchainResponse(BaseResponse):
             instancetype,
             logpublishingconfiguration,
         )
-        return 200, headers, json.dumps(response)
-
-    @exception_handler
-    def nodeid_response(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[misc]
-        self.setup_class(request, full_url, headers)
-        return self._nodeid_response(request, full_url, headers)
-
-    def _nodeid_response(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[return]
-        method = request.method
-        network_id = networkid_from_managedblockchain_url(full_url)
-        member_id = memberid_from_managedblockchain_request(full_url, self.body)
-        node_id = nodeid_from_managedblockchain_url(full_url)
-        if method == "GET":
-            return self._nodeid_response_get(network_id, member_id, node_id, headers)
-        elif method == "PATCH":
-            json_body = json.loads(self.body)
-            return self._nodeid_response_patch(
-                network_id, member_id, node_id, json_body, headers
-            )
-        elif method == "DELETE":
-            return self._nodeid_response_delete(network_id, member_id, node_id, headers)
+        return json.dumps(response)
 
-    def _nodeid_response_get(
-        self, network_id: str, member_id: str, node_id: str, headers: Any
-    ) -> TYPE_RESPONSE:
+    def get_node(self) -> str:
+        network_id = networkid_from_managedblockchain_url(self.path)
+        member_id = memberid_from_managedblockchain_request(self.uri, self.body)
+        node_id = nodeid_from_managedblockchain_url(self.path)
         node = self.backend.get_node(network_id, member_id, node_id)
-        response = json.dumps({"Node": node.get_format()})
-        headers["content-type"] = "application/json"
-        return 200, headers, response
+        return json.dumps({"Node": node.get_format()})
 
-    def _nodeid_response_patch(
-        self,
-        network_id: str,
-        member_id: str,
-        node_id: str,
-        json_body: Dict[str, Any],
-        headers: Any,
-    ) -> TYPE_RESPONSE:
-        logpublishingconfiguration = json_body
+    def update_node(self) -> str:
+        network_id = networkid_from_managedblockchain_url(self.path)
+        member_id = memberid_from_managedblockchain_request(self.uri, self.body)
+        node_id = nodeid_from_managedblockchain_url(self.path)
         self.backend.update_node(
-            network_id, member_id, node_id, logpublishingconfiguration
+            network_id, member_id, node_id, logpublishingconfiguration=self.body
         )
-        return 200, headers, ""
+        return ""
 
-    def _nodeid_response_delete(
-        self, network_id: str, member_id: str, node_id: str, headers: Any
-    ) -> TYPE_RESPONSE:
+    def delete_node(self) -> str:
+        network_id = networkid_from_managedblockchain_url(self.path)
+        member_id = memberid_from_managedblockchain_request(self.uri, self.body)
+        node_id = nodeid_from_managedblockchain_url(self.path)
         self.backend.delete_node(network_id, member_id, node_id)
-        headers["content-type"] = "application/json"
-        return 200, headers, ""
+        return ""
diff --git a/moto/managedblockchain/urls.py b/moto/managedblockchain/urls.py
index f3f54c134..0ac010f55 100644
--- a/moto/managedblockchain/urls.py
+++ b/moto/managedblockchain/urls.py
@@ -3,47 +3,19 @@ from .responses import ManagedBlockchainResponse
 url_bases = [r"https?://managedblockchain\.(.+)\.amazonaws.com"]
 
 url_paths = {
-    "{0}/networks$": ManagedBlockchainResponse.method_dispatch(
-        ManagedBlockchainResponse.network_response
-    ),
-    "{0}/networks/(?P<networkid>[^/.]+)$": ManagedBlockchainResponse.method_dispatch(
-        ManagedBlockchainResponse.networkid_response
-    ),
-    "{0}/networks/(?P<networkid>[^/.]+)/proposals$": ManagedBlockchainResponse.method_dispatch(
-        ManagedBlockchainResponse.proposal_response
-    ),
-    "{0}/networks/(?P<networkid>[^/.]+)/proposals/(?P<proposalid>[^/.]+)$": ManagedBlockchainResponse.method_dispatch(
-        ManagedBlockchainResponse.proposalid_response
-    ),
-    "{0}/networks/(?P<networkid>[^/.]+)/proposals/(?P<proposalid>[^/.]+)/votes$": ManagedBlockchainResponse.method_dispatch(
-        ManagedBlockchainResponse.proposal_votes_response
-    ),
-    "{0}/invitations$": ManagedBlockchainResponse.method_dispatch(
-        ManagedBlockchainResponse.invitation_response
-    ),
-    "{0}/invitations/(?P<invitationid>[^/.]+)$": ManagedBlockchainResponse.method_dispatch(
-        ManagedBlockchainResponse.invitationid_response
-    ),
-    "{0}/networks/(?P<networkid>[^/.]+)/members$": ManagedBlockchainResponse.method_dispatch(
-        ManagedBlockchainResponse.member_response
-    ),
-    "{0}/networks/(?P<networkid>[^/.]+)/members/(?P<memberid>[^/.]+)$": ManagedBlockchainResponse.method_dispatch(
-        ManagedBlockchainResponse.memberid_response
-    ),
-    "{0}/networks/(?P<networkid>[^/.]+)/members/(?P<memberid>[^/.]+)/nodes$": ManagedBlockchainResponse.method_dispatch(
-        ManagedBlockchainResponse.node_response
-    ),
-    "{0}/networks/(?P<networkid>[^/.]+)/members/(?P<memberid>[^/.]+)/nodes?(?P<querys>[^/.]+)$": ManagedBlockchainResponse.method_dispatch(
-        ManagedBlockchainResponse.node_response
-    ),
-    "{0}/networks/(?P<networkid>[^/.]+)/members/(?P<memberid>[^/.]+)/nodes/(?P<nodeid>[^/.]+)$": ManagedBlockchainResponse.method_dispatch(
-        ManagedBlockchainResponse.nodeid_response
-    ),
+    "{0}/networks$": ManagedBlockchainResponse.dispatch,
+    "{0}/networks/(?P<networkid>[^/.]+)$": ManagedBlockchainResponse.dispatch,
+    "{0}/networks/(?P<networkid>[^/.]+)/proposals$": ManagedBlockchainResponse.dispatch,
+    "{0}/networks/(?P<networkid>[^/.]+)/proposals/(?P<proposalid>[^/.]+)$": ManagedBlockchainResponse.dispatch,
+    "{0}/networks/(?P<networkid>[^/.]+)/proposals/(?P<proposalid>[^/.]+)/votes$": ManagedBlockchainResponse.dispatch,
+    "{0}/invitations$": ManagedBlockchainResponse.dispatch,
+    "{0}/invitations/(?P<invitationid>[^/.]+)$": ManagedBlockchainResponse.dispatch,
+    "{0}/networks/(?P<networkid>[^/.]+)/members$": ManagedBlockchainResponse.dispatch,
+    "{0}/networks/(?P<networkid>[^/.]+)/members/(?P<memberid>[^/.]+)$": ManagedBlockchainResponse.dispatch,
+    "{0}/networks/(?P<networkid>[^/.]+)/members/(?P<memberid>[^/.]+)/nodes$": ManagedBlockchainResponse.dispatch,
+    "{0}/networks/(?P<networkid>[^/.]+)/members/(?P<memberid>[^/.]+)/nodes?(?P<querys>[^/.]+)$": ManagedBlockchainResponse.dispatch,
+    "{0}/networks/(?P<networkid>[^/.]+)/members/(?P<memberid>[^/.]+)/nodes/(?P<nodeid>[^/.]+)$": ManagedBlockchainResponse.dispatch,
     # >= botocore 1.19.41 (API change - memberId is now part of query-string or body)
-    "{0}/networks/(?P<networkid>[^/.]+)/nodes$": ManagedBlockchainResponse.method_dispatch(
-        ManagedBlockchainResponse.node_response
-    ),
-    "{0}/networks/(?P<networkid>[^/.]+)/nodes/(?P<nodeid>[^/.]+)$": ManagedBlockchainResponse.method_dispatch(
-        ManagedBlockchainResponse.nodeid_response
-    ),
+    "{0}/networks/(?P<networkid>[^/.]+)/nodes$": ManagedBlockchainResponse.dispatch,
+    "{0}/networks/(?P<networkid>[^/.]+)/nodes/(?P<nodeid>[^/.]+)$": ManagedBlockchainResponse.dispatch,
 }
diff --git a/moto/moto_api/_internal/models.py b/moto/moto_api/_internal/models.py
index ab9d02914..af75c287b 100644
--- a/moto/moto_api/_internal/models.py
+++ b/moto/moto_api/_internal/models.py
@@ -46,6 +46,20 @@ class MotoAPIBackend(BaseBackend):
         results = QueryResults(rows=rows, column_info=column_info)
         backend.query_results_queue.append(results)
 
+    def set_sagemaker_result(
+        self,
+        body: str,
+        content_type: str,
+        prod_variant: str,
+        custom_attrs: str,
+        account_id: str,
+        region: str,
+    ) -> None:
+        from moto.sagemakerruntime.models import sagemakerruntime_backends
+
+        backend = sagemakerruntime_backends[account_id][region]
+        backend.results_queue.append((body, content_type, prod_variant, custom_attrs))
+
     def set_rds_data_result(
         self,
         records: Optional[List[List[Dict[str, Any]]]],
diff --git a/moto/moto_api/_internal/responses.py b/moto/moto_api/_internal/responses.py
index d95c1c011..e4c885942 100644
--- a/moto/moto_api/_internal/responses.py
+++ b/moto/moto_api/_internal/responses.py
@@ -168,6 +168,35 @@ class MotoAPIResponse(BaseResponse):
             )
         return 201, {}, ""
 
+    def set_sagemaker_result(
+        self,
+        request: Any,
+        full_url: str,  # pylint: disable=unused-argument
+        headers: Any,
+    ) -> TYPE_RESPONSE:
+        from .models import moto_api_backend
+
+        request_body_size = int(headers["Content-Length"])
+        body = request.environ["wsgi.input"].read(request_body_size).decode("utf-8")
+        body = json.loads(body)
+        account_id = body.get("account_id", DEFAULT_ACCOUNT_ID)
+        region = body.get("region", "us-east-1")
+
+        for result in body.get("results", []):
+            body = result["Body"]
+            content_type = result.get("ContentType")
+            prod_variant = result.get("InvokedProductionVariant")
+            custom_attrs = result.get("CustomAttributes")
+            moto_api_backend.set_sagemaker_result(
+                body=body,
+                content_type=content_type,
+                prod_variant=prod_variant,
+                custom_attrs=custom_attrs,
+                account_id=account_id,
+                region=region,
+            )
+        return 201, {}, ""
+
     def set_rds_data_result(
         self,
         request: Any,
diff --git a/moto/moto_api/_internal/urls.py b/moto/moto_api/_internal/urls.py
index 41506174c..6b9977355 100644
--- a/moto/moto_api/_internal/urls.py
+++ b/moto/moto_api/_internal/urls.py
@@ -13,6 +13,7 @@ url_paths = {
     "{0}/moto-api/reset-auth": response_instance.reset_auth_response,
     "{0}/moto-api/seed": response_instance.seed,
     "{0}/moto-api/static/athena/query-results": response_instance.set_athena_result,
+    "{0}/moto-api/static/sagemaker/endpoint-results": response_instance.set_sagemaker_result,
     "{0}/moto-api/static/rds-data/statement-results": response_instance.set_rds_data_result,
     "{0}/moto-api/state-manager/get-transition": response_instance.get_transition,
     "{0}/moto-api/state-manager/set-transition": response_instance.set_transition,
diff --git a/moto/moto_server/werkzeug_app.py b/moto/moto_server/werkzeug_app.py
index 5dea96f0c..9de05a1d1 100644
--- a/moto/moto_server/werkzeug_app.py
+++ b/moto/moto_server/werkzeug_app.py
@@ -153,7 +153,10 @@ class DomainDispatcherApplication:
                 else:
                     host = "dynamodb"
         elif service == "sagemaker":
-            host = f"api.{service}.{region}.amazonaws.com"
+            if environ["PATH_INFO"].endswith("invocations"):
+                host = f"runtime.{service}.{region}.amazonaws.com"
+            else:
+                host = f"api.{service}.{region}.amazonaws.com"
         elif service == "timestream":
             host = f"ingest.{service}.{region}.amazonaws.com"
         elif service == "s3" and (
diff --git a/moto/sagemakerruntime/__init__.py b/moto/sagemakerruntime/__init__.py
new file mode 100644
index 000000000..8cdc930a6
--- /dev/null
+++ b/moto/sagemakerruntime/__init__.py
@@ -0,0 +1,5 @@
+"""sagemakerruntime module initialization; sets value for base decorator."""
+from .models import sagemakerruntime_backends
+from ..core.models import base_decorator
+
+mock_sagemakerruntime = base_decorator(sagemakerruntime_backends)
diff --git a/moto/sagemakerruntime/models.py b/moto/sagemakerruntime/models.py
new file mode 100644
index 000000000..c28d3931f
--- /dev/null
+++ b/moto/sagemakerruntime/models.py
@@ -0,0 +1,65 @@
+from moto.core import BaseBackend, BackendDict
+from typing import Dict, List, Tuple
+
+
+class SageMakerRuntimeBackend(BaseBackend):
+    """Implementation of SageMakerRuntime APIs."""
+
+    def __init__(self, region_name: str, account_id: str):
+        super().__init__(region_name, account_id)
+        self.results: Dict[str, Dict[bytes, Tuple[str, str, str, str]]] = {}
+        self.results_queue: List[Tuple[str, str, str, str]] = []
+
+    def invoke_endpoint(
+        self, endpoint_name: str, unique_repr: bytes
+    ) -> Tuple[str, str, str, str]:
+        """
+        This call will return static data by default.
+
+        You can use a dedicated API to override this, by configuring a queue of expected results.
+
+        A request to `get_query_results` will take the first result from that queue. Subsequent requests using the same details will return the same result. Other requests using a different QueryExecutionId will take the next result from the queue, or return static data if the queue is empty.
+
+        Configuring this queue by making an HTTP request to `/moto-api/static/sagemaker/endpoint-results`. An example invocation looks like this:
+
+        .. sourcecode:: python
+
+            expected_results = {
+                "account_id": "123456789012",  # This is the default - can be omitted
+                "region": "us-east-1",  # This is the default - can be omitted
+                "results": [
+                    {
+                         "Body": "first body",
+                         "ContentType": "text/xml",
+                         "InvokedProductionVariant": "prod",
+                         "CustomAttributes": "my_attr",
+                     },
+                    # other results as required
+                ],
+            }
+            requests.post(
+                "http://motoapi.amazonaws.com:5000/moto-api/static/sagemaker/endpoint-results",
+                json=expected_results,
+            )
+
+            client = boto3.client("sagemaker", region_name="us-east-1")
+            details = client.invoke_endpoint(EndpointName="asdf", Body="qwer")
+
+        """
+        if endpoint_name not in self.results:
+            self.results[endpoint_name] = {}
+        if unique_repr in self.results[endpoint_name]:
+            return self.results[endpoint_name][unique_repr]
+        if self.results_queue:
+            self.results[endpoint_name][unique_repr] = self.results_queue.pop(0)
+        else:
+            self.results[endpoint_name][unique_repr] = (
+                "body",
+                "content_type",
+                "invoked_production_variant",
+                "custom_attributes",
+            )
+        return self.results[endpoint_name][unique_repr]
+
+
+sagemakerruntime_backends = BackendDict(SageMakerRuntimeBackend, "sagemaker-runtime")
diff --git a/moto/sagemakerruntime/responses.py b/moto/sagemakerruntime/responses.py
new file mode 100644
index 000000000..24cc48633
--- /dev/null
+++ b/moto/sagemakerruntime/responses.py
@@ -0,0 +1,44 @@
+import base64
+import json
+
+from moto.core.common_types import TYPE_RESPONSE
+from moto.core.responses import BaseResponse
+from .models import sagemakerruntime_backends, SageMakerRuntimeBackend
+
+
+class SageMakerRuntimeResponse(BaseResponse):
+    """Handler for SageMakerRuntime requests and responses."""
+
+    def __init__(self) -> None:
+        super().__init__(service_name="sagemaker-runtime")
+
+    @property
+    def sagemakerruntime_backend(self) -> SageMakerRuntimeBackend:
+        """Return backend instance specific for this region."""
+        return sagemakerruntime_backends[self.current_account][self.region]
+
+    def invoke_endpoint(self) -> TYPE_RESPONSE:
+        params = self._get_params()
+        unique_repr = {
+            key: value
+            for key, value in self.headers.items()
+            if key.lower().startswith("x-amzn-sagemaker")
+        }
+        unique_repr["Accept"] = self.headers.get("Accept")
+        unique_repr["Body"] = self.body
+        endpoint_name = params.get("EndpointName")
+        (
+            body,
+            content_type,
+            invoked_production_variant,
+            custom_attributes,
+        ) = self.sagemakerruntime_backend.invoke_endpoint(
+            endpoint_name=endpoint_name,  # type: ignore[arg-type]
+            unique_repr=base64.b64encode(json.dumps(unique_repr).encode("utf-8")),
+        )
+        headers = {"Content-Type": content_type}
+        if invoked_production_variant:
+            headers["x-Amzn-Invoked-Production-Variant"] = invoked_production_variant
+        if custom_attributes:
+            headers["X-Amzn-SageMaker-Custom-Attributes"] = custom_attributes
+        return 200, headers, body
diff --git a/moto/sagemakerruntime/urls.py b/moto/sagemakerruntime/urls.py
new file mode 100644
index 000000000..01c753437
--- /dev/null
+++ b/moto/sagemakerruntime/urls.py
@@ -0,0 +1,14 @@
+"""sagemakerruntime base URL and path."""
+from .responses import SageMakerRuntimeResponse
+
+url_bases = [
+    r"https?://runtime\.sagemaker\.(.+)\.amazonaws\.com",
+]
+
+
+response = SageMakerRuntimeResponse()
+
+
+url_paths = {
+    "{0}/endpoints/(?P<name>[^/]+)/invocations$": response.dispatch,
+}


Test patch:
diff --git a/tests/test_iam/test_iam_policies.py b/tests/test_iam/test_iam_policies.py
index b7ffe8984..6f917718b 100644
--- a/tests/test_iam/test_iam_policies.py
+++ b/tests/test_iam/test_iam_policies.py
@@ -1607,6 +1607,20 @@ valid_policy_documents = [
             },
         ],
     },
+    {
+        "Version": "2012-10-17",
+        "Statement": [
+            {
+                "Sid": "",
+                "Effect": "Allow",
+                "Action": ["s3:*"],
+                "Resource": [
+                    "arn:aws:s3:us-west-2:123456789012:accesspoint/my-access-point",
+                    "arn:aws:s3:us-west-2:123456789012:accesspoint/my-access-point/object/*",
+                ],
+            },
+        ],
+    },
 ]
 
 
diff --git a/tests/test_managedblockchain/test_managedblockchain_invitations.py b/tests/test_managedblockchain/test_managedblockchain_invitations.py
index 7e0564898..d097f0f32 100644
--- a/tests/test_managedblockchain/test_managedblockchain_invitations.py
+++ b/tests/test_managedblockchain/test_managedblockchain_invitations.py
@@ -6,129 +6,112 @@ from moto import mock_managedblockchain
 from . import helpers
 
 
-@mock_managedblockchain
-def test_create_2_invitations():
-    conn = boto3.client("managedblockchain", region_name="us-east-1")
-
-    # Create network
-    response = conn.create_network(
-        Name="testnetwork1",
-        Framework="HYPERLEDGER_FABRIC",
-        FrameworkVersion="1.2",
-        FrameworkConfiguration=helpers.default_frameworkconfiguration,
-        VotingPolicy=helpers.default_votingpolicy,
-        MemberConfiguration=helpers.default_memberconfiguration,
-    )
-    network_id = response["NetworkId"]
-    member_id = response["MemberId"]
-
-    # Create proposal
-    proposal_id = conn.create_proposal(
-        NetworkId=network_id,
-        MemberId=member_id,
-        Actions=helpers.multiple_policy_actions,
-    )["ProposalId"]
-
-    # Get proposal details
-    response = conn.get_proposal(NetworkId=network_id, ProposalId=proposal_id)
-    assert response["Proposal"]["NetworkId"] == network_id
-    assert response["Proposal"]["Status"] == "IN_PROGRESS"
-
-    # Vote yes
-    conn.vote_on_proposal(
-        NetworkId=network_id,
-        ProposalId=proposal_id,
-        VoterMemberId=member_id,
-        Vote="YES",
-    )
-
-    # Get the invitation
-    response = conn.list_invitations()
-    assert len(response["Invitations"]) == 2
-    assert response["Invitations"][0]["NetworkSummary"]["Id"] == network_id
-    assert response["Invitations"][0]["Status"] == "PENDING"
-    assert response["Invitations"][1]["NetworkSummary"]["Id"] == network_id
-    assert response["Invitations"][1]["Status"] == "PENDING"
-
-
-@mock_managedblockchain
-def test_reject_invitation():
-    conn = boto3.client("managedblockchain", region_name="us-east-1")
-
-    # Create network
-    response = conn.create_network(
-        Name="testnetwork1",
-        Framework="HYPERLEDGER_FABRIC",
-        FrameworkVersion="1.2",
-        FrameworkConfiguration=helpers.default_frameworkconfiguration,
-        VotingPolicy=helpers.default_votingpolicy,
-        MemberConfiguration=helpers.default_memberconfiguration,
-    )
-    network_id = response["NetworkId"]
-    member_id = response["MemberId"]
-
-    # Create proposal
-    proposal_id = conn.create_proposal(
-        NetworkId=network_id, MemberId=member_id, Actions=helpers.default_policy_actions
-    )["ProposalId"]
-
-    # Get proposal details
-    response = conn.get_proposal(NetworkId=network_id, ProposalId=proposal_id)
-    assert response["Proposal"]["NetworkId"] == network_id
-    assert response["Proposal"]["Status"] == "IN_PROGRESS"
-
-    # Vote yes
-    conn.vote_on_proposal(
-        NetworkId=network_id,
-        ProposalId=proposal_id,
-        VoterMemberId=member_id,
-        Vote="YES",
-    )
-
-    # Get the invitation
-    response = conn.list_invitations()
-    assert response["Invitations"][0]["NetworkSummary"]["Id"] == network_id
-    assert response["Invitations"][0]["Status"] == "PENDING"
-    invitation_id = response["Invitations"][0]["InvitationId"]
-
-    # Reject - thanks but no thanks
-    conn.reject_invitation(InvitationId=invitation_id)
-
-    # Check the invitation status
-    response = conn.list_invitations()
-    assert response["Invitations"][0]["InvitationId"] == invitation_id
-    assert response["Invitations"][0]["Status"] == "REJECTED"
-
-
-@mock_managedblockchain
-def test_reject_invitation_badinvitation():
-    conn = boto3.client("managedblockchain", region_name="us-east-1")
-
-    # Create network - need a good network
-    response = conn.create_network(
-        Name="testnetwork1",
-        Framework="HYPERLEDGER_FABRIC",
-        FrameworkVersion="1.2",
-        FrameworkConfiguration=helpers.default_frameworkconfiguration,
-        VotingPolicy=helpers.default_votingpolicy,
-        MemberConfiguration=helpers.default_memberconfiguration,
-    )
-    network_id = response["NetworkId"]
-    member_id = response["MemberId"]
-
-    proposal_id = conn.create_proposal(
-        NetworkId=network_id, MemberId=member_id, Actions=helpers.default_policy_actions
-    )["ProposalId"]
-
-    conn.vote_on_proposal(
-        NetworkId=network_id,
-        ProposalId=proposal_id,
-        VoterMemberId=member_id,
-        Vote="YES",
-    )
-
-    with pytest.raises(ClientError) as ex:
-        conn.reject_invitation(InvitationId="in-ABCDEFGHIJKLMNOP0123456789")
-    err = ex.value.response["Error"]
-    assert err["Code"] == "ResourceNotFoundException"
-    assert "InvitationId in-ABCDEFGHIJKLMNOP0123456789 not found." in err["Message"]
+class TestManagedBlockchainInvitations:
+    mock = mock_managedblockchain()
+
+    @classmethod
+    def setup_class(cls):
+        cls.mock.start()
+        cls.conn = boto3.client("managedblockchain", region_name="us-east-1")
+        response = cls.conn.create_network(
+            Name="testnetwork1",
+            Framework="HYPERLEDGER_FABRIC",
+            FrameworkVersion="1.2",
+            FrameworkConfiguration=helpers.default_frameworkconfiguration,
+            VotingPolicy=helpers.default_votingpolicy,
+            MemberConfiguration=helpers.default_memberconfiguration,
+        )
+        cls.network_id = response["NetworkId"]
+        cls.member_id = response["MemberId"]
+
+    @classmethod
+    def teardown_class(cls):
+        cls.mock.stop()
+
+    def test_create_2_invitations(self):
+        # Create proposal
+        proposal_id = self.conn.create_proposal(
+            NetworkId=self.network_id,
+            MemberId=self.member_id,
+            Actions=helpers.multiple_policy_actions,
+        )["ProposalId"]
+
+        # Get proposal details
+        response = self.conn.get_proposal(
+            NetworkId=self.network_id, ProposalId=proposal_id
+        )
+        assert response["Proposal"]["NetworkId"] == self.network_id
+        assert response["Proposal"]["Status"] == "IN_PROGRESS"
+
+        # Vote yes
+        self.conn.vote_on_proposal(
+            NetworkId=self.network_id,
+            ProposalId=proposal_id,
+            VoterMemberId=self.member_id,
+            Vote="YES",
+        )
+
+        # Get the invitation
+        response = self.conn.list_invitations()
+        assert len(response["Invitations"]) == 2
+        assert response["Invitations"][0]["NetworkSummary"]["Id"] == self.network_id
+        assert response["Invitations"][0]["Status"] == "PENDING"
+        assert response["Invitations"][1]["NetworkSummary"]["Id"] == self.network_id
+        assert response["Invitations"][1]["Status"] == "PENDING"
+
+    def test_reject_invitation(self):
+        # Create proposal
+        proposal_id = self.conn.create_proposal(
+            NetworkId=self.network_id,
+            MemberId=self.member_id,
+            Actions=helpers.default_policy_actions,
+        )["ProposalId"]
+
+        # Get proposal details
+        response = self.conn.get_proposal(
+            NetworkId=self.network_id, ProposalId=proposal_id
+        )
+        assert response["Proposal"]["NetworkId"] == self.network_id
+        assert response["Proposal"]["Status"] == "IN_PROGRESS"
+
+        # Vote yes
+        self.conn.vote_on_proposal(
+            NetworkId=self.network_id,
+            ProposalId=proposal_id,
+            VoterMemberId=self.member_id,
+            Vote="YES",
+        )
+
+        # Get the invitation
+        response = self.conn.list_invitations()
+        assert response["Invitations"][0]["NetworkSummary"]["Id"] == self.network_id
+        assert response["Invitations"][0]["Status"] == "PENDING"
+        invitation_id = response["Invitations"][0]["InvitationId"]
+
+        # Reject - thanks but no thanks
+        self.conn.reject_invitation(InvitationId=invitation_id)
+
+        # Check the invitation status
+        response = self.conn.list_invitations()
+        assert response["Invitations"][0]["InvitationId"] == invitation_id
+        assert response["Invitations"][0]["Status"] == "REJECTED"
+
+    def test_reject_invitation_badinvitation(self):
+        proposal_id = self.conn.create_proposal(
+            NetworkId=self.network_id,
+            MemberId=self.member_id,
+            Actions=helpers.default_policy_actions,
+        )["ProposalId"]
+
+        self.conn.vote_on_proposal(
+            NetworkId=self.network_id,
+            ProposalId=proposal_id,
+            VoterMemberId=self.member_id,
+            Vote="YES",
+        )
+
+        with pytest.raises(ClientError) as ex:
+            self.conn.reject_invitation(InvitationId="in-ABCDEFGHIJKLMNOP0123456789")
+        err = ex.value.response["Error"]
+        assert err["Code"] == "ResourceNotFoundException"
+        assert "InvitationId in-ABCDEFGHIJKLMNOP0123456789 not found." in err["Message"]
diff --git a/tests/test_managedblockchain/test_managedblockchain_members.py b/tests/test_managedblockchain/test_managedblockchain_members.py
index 3b41e24c5..40030e5d6 100644
--- a/tests/test_managedblockchain/test_managedblockchain_members.py
+++ b/tests/test_managedblockchain/test_managedblockchain_members.py
@@ -1,6 +1,7 @@
 import boto3
 import pytest
 
+from botocore.config import Config
 from botocore.exceptions import ClientError, ParamValidationError
 from moto import mock_managedblockchain
 from . import helpers
@@ -280,7 +281,11 @@ def test_invite_and_remove_member():
 
 @mock_managedblockchain
 def test_create_too_many_members():
-    conn = boto3.client("managedblockchain", region_name="us-east-1")
+    # This test throws a ResourceLimitException, with HTTP status code 429
+    # Boto3 automatically retries a request with that status code up to 5 times
+    # Retrying is not going to make a difference to the output though...
+    config = Config(retries={"max_attempts": 1, "mode": "standard"})
+    conn = boto3.client("managedblockchain", region_name="us-east-1", config=config)
 
     # Create network
     response = conn.create_network(
diff --git a/tests/test_managedblockchain/test_managedblockchain_networks.py b/tests/test_managedblockchain/test_managedblockchain_networks.py
index 38882b85c..4ee6844a5 100644
--- a/tests/test_managedblockchain/test_managedblockchain_networks.py
+++ b/tests/test_managedblockchain/test_managedblockchain_networks.py
@@ -36,7 +36,7 @@ def test_create_network():
 
 
 @mock_managedblockchain
-def test_create_network_withopts():
+def test_create_network_with_description():
     conn = boto3.client("managedblockchain", region_name="us-east-1")
 
     response = conn.create_network(
@@ -49,11 +49,6 @@ def test_create_network_withopts():
         MemberConfiguration=helpers.default_memberconfiguration,
     )
     network_id = response["NetworkId"]
-    member_id = response["MemberId"]
-    assert network_id.startswith("n-")
-    assert len(network_id) == 28
-    assert member_id.startswith("m-")
-    assert len(member_id) == 28
 
     # Find in full list
     mbcnetworks = conn.list_networks()["Networks"]
diff --git a/tests/test_managedblockchain/test_managedblockchain_nodes.py b/tests/test_managedblockchain/test_managedblockchain_nodes.py
index e3ca9b8fb..d53ae4404 100644
--- a/tests/test_managedblockchain/test_managedblockchain_nodes.py
+++ b/tests/test_managedblockchain/test_managedblockchain_nodes.py
@@ -1,6 +1,7 @@
 import boto3
 import pytest
 
+from botocore.config import Config
 from botocore.exceptions import ClientError
 from moto import mock_managedblockchain
 from . import helpers
@@ -145,7 +146,11 @@ def test_create_node_standard_edition():
 
 @mock_managedblockchain
 def test_create_too_many_nodes():
-    conn = boto3.client("managedblockchain", region_name="us-east-1")
+    # This test throws a ResourceLimitException, with HTTP status code 429
+    # Boto3 automatically retries a request with that status code up to 5 times
+    # Retrying is not going to make a difference to the output though...
+    config = Config(retries={"max_attempts": 1, "mode": "standard"})
+    conn = boto3.client("managedblockchain", region_name="us-east-1", config=config)
 
     # Create network
     response = conn.create_network(
diff --git a/tests/test_sagemakerruntime/__init__.py b/tests/test_sagemakerruntime/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/test_sagemakerruntime/test_sagemakerruntime.py b/tests/test_sagemakerruntime/test_sagemakerruntime.py
new file mode 100644
index 000000000..60b256d5a
--- /dev/null
+++ b/tests/test_sagemakerruntime/test_sagemakerruntime.py
@@ -0,0 +1,56 @@
+import boto3
+import requests
+
+from moto import mock_sagemakerruntime, settings
+
+# See our Development Tips on writing tests for hints on how to write good tests:
+# http://docs.getmoto.org/en/latest/docs/contributing/development_tips/tests.html
+
+
+@mock_sagemakerruntime
+def test_invoke_endpoint__default_results():
+    client = boto3.client("sagemaker-runtime", region_name="ap-southeast-1")
+    body = client.invoke_endpoint(
+        EndpointName="asdf", Body="qwer", Accept="sth", TargetModel="tm"
+    )
+
+    assert body["Body"].read() == b"body"
+    assert body["CustomAttributes"] == "custom_attributes"
+
+
+@mock_sagemakerruntime
+def test_invoke_endpoint():
+    client = boto3.client("sagemaker-runtime", region_name="us-east-1")
+    base_url = (
+        "localhost:5000" if settings.TEST_SERVER_MODE else "motoapi.amazonaws.com"
+    )
+
+    sagemaker_result = {
+        "results": [
+            {
+                "Body": "first body",
+                "ContentType": "text/xml",
+                "InvokedProductionVariant": "prod",
+                "CustomAttributes": "my_attr",
+            },
+            {"Body": "second body"},
+        ]
+    }
+    requests.post(
+        f"http://{base_url}/moto-api/static/sagemaker/endpoint-results",
+        json=sagemaker_result,
+    )
+
+    # Return the first item from the list
+    body = client.invoke_endpoint(EndpointName="asdf", Body="qwer")
+    assert body["Body"].read() == b"first body"
+
+    # Same input -> same output
+    body = client.invoke_endpoint(EndpointName="asdf", Body="qwer")
+    assert body["Body"].read() == b"first body"
+
+    # Different input -> second item
+    body = client.invoke_endpoint(
+        EndpointName="asdf", Body="qwer", Accept="sth", TargetModel="tm"
+    )
+    assert body["Body"].read() == b"second body"