Repository: strawberry-graphql/strawberry

Issue:
Fixes to single-response-requests on `graphql-transport-ws`
<!--- Provide a general summary of your changes in the title above. -->
Various problems with single-response-requests (as opposed ot streaming requests) are tackled

<!--- This template is entirely optional and can be removed, but is here to help both you and us. -->
<!--- Anything on lines wrapped in comments like these will not show up in the final text. -->

## Description

single-response-request are now handled as *tasks* and can be overlapped with other requests
on the same connection, can be cancelled, and must have unique requests IDs.

## Types of Changes

<!--- What types of changes does your pull request introduce? Put an `x` in all the boxes that apply. -->
- [ ] Core
- [x] Bugfix
- [ ] New feature
- [ ] Enhancement/optimization
- [ ] Documentation

## Issues Fixed or Closed by This PR

* #1731
* #1733
* #1734
* #1738

## Checklist

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
- [x] My code follows the code style of this project.
- [ ] My change requires a change to the documentation.
- [ ] I have updated the documentation accordingly.
- [x] I have read the CONTRIBUTING document.
- [x] I have added tests to cover my changes.
- [x] I have tested the changes and verified that they work and don't break anything (as well as I can manage).



Discussion:

Resolving patch:
diff --git a/strawberry/aiohttp/handlers/graphql_transport_ws_handler.py b/strawberry/aiohttp/handlers/graphql_transport_ws_handler.py
--- a/strawberry/aiohttp/handlers/graphql_transport_ws_handler.py
+++ b/strawberry/aiohttp/handlers/graphql_transport_ws_handler.py
@@ -50,5 +50,6 @@ async def handle_request(self) -> web.StreamResponse:
         finally:
             for operation_id in list(self.subscriptions.keys()):
                 await self.cleanup_operation(operation_id)
+            await self.reap_completed_tasks()
 
         return self._ws
diff --git a/strawberry/asgi/handlers/graphql_transport_ws_handler.py b/strawberry/asgi/handlers/graphql_transport_ws_handler.py
--- a/strawberry/asgi/handlers/graphql_transport_ws_handler.py
+++ b/strawberry/asgi/handlers/graphql_transport_ws_handler.py
@@ -56,3 +56,4 @@ async def handle_request(self) -> None:
 
             for operation_id in list(self.subscriptions.keys()):
                 await self.cleanup_operation(operation_id)
+            await self.reap_completed_tasks()
diff --git a/strawberry/subscriptions/protocols/graphql_transport_ws/handlers.py b/strawberry/subscriptions/protocols/graphql_transport_ws/handlers.py
--- a/strawberry/subscriptions/protocols/graphql_transport_ws/handlers.py
+++ b/strawberry/subscriptions/protocols/graphql_transport_ws/handlers.py
@@ -2,7 +2,7 @@
 from abc import ABC, abstractmethod
 from contextlib import suppress
 from datetime import timedelta
-from typing import Any, AsyncGenerator, Callable, Dict, Optional
+from typing import Any, AsyncGenerator, Callable, Dict, List, Optional
 
 from graphql import (
     ExecutionResult as GraphQLExecutionResult,
@@ -45,6 +45,7 @@ def __init__(
         self.connection_acknowledged = False
         self.subscriptions: Dict[str, AsyncGenerator] = {}
         self.tasks: Dict[str, asyncio.Task] = {}
+        self.completed_tasks: List[asyncio.Task] = []
 
     @abstractmethod
     async def get_context(self) -> Any:
@@ -117,6 +118,7 @@ async def handle_message(self, message: dict):
             handler_arg = "Failed to parse message"
 
         await handler(handler_arg)
+        await self.reap_completed_tasks()
 
     async def handle_connection_init(self, message: ConnectionInitMessage) -> None:
         if self.connection_init_received:
@@ -153,43 +155,7 @@ async def handle_subscribe(self, message: SubscribeMessage) -> None:
             await self.close(code=4400, reason="Can't get GraphQL operation type")
             return
 
-        if operation_type == OperationType.SUBSCRIPTION:
-            return await self.handle_streaming_operation(message)
-        else:
-            return await self.handle_single_result_operation(message)
-
-    async def handle_single_result_operation(self, message: SubscribeMessage):
-        if self.debug:  # pragma: no cover
-            pretty_print_graphql_operation(
-                message.payload.operationName,
-                message.payload.query,
-                message.payload.variables,
-            )
-
-        context = await self.get_context()
-        root_value = await self.get_root_value()
-
-        result = await self.schema.execute(
-            query=message.payload.query,
-            variable_values=message.payload.variables,
-            context_value=context,
-            root_value=root_value,
-            operation_name=message.payload.operationName,
-        )
-
-        if result.errors:
-            payload = [format_graphql_error(result.errors[0])]
-            await self.send_message(ErrorMessage(id=message.id, payload=payload))
-            self.schema.process_errors(result.errors)
-            return
-
-        await self.send_message(
-            NextMessage(id=message.id, payload={"data": result.data})
-        )
-        await self.send_message(CompleteMessage(id=message.id))
-
-    async def handle_streaming_operation(self, message: SubscribeMessage) -> None:
-        if message.id in self.subscriptions.keys():
+        if message.id in self.subscriptions:
             reason = f"Subscriber for {message.id} already exists"
             await self.close(code=4409, reason=reason)
             return
@@ -204,14 +170,29 @@ async def handle_streaming_operation(self, message: SubscribeMessage) -> None:
         context = await self.get_context()
         root_value = await self.get_root_value()
 
-        result_source = await self.schema.subscribe(
-            query=message.payload.query,
-            variable_values=message.payload.variables,
-            operation_name=message.payload.operationName,
-            context_value=context,
-            root_value=root_value,
-        )
-
+        # Get an AsyncGenerator yielding the results
+        if operation_type == OperationType.SUBSCRIPTION:
+            result_source = await self.schema.subscribe(
+                query=message.payload.query,
+                variable_values=message.payload.variables,
+                operation_name=message.payload.operationName,
+                context_value=context,
+                root_value=root_value,
+            )
+        else:
+            # create AsyncGenerator returning a single result
+            async def get_result_source():
+                yield await self.schema.execute(
+                    query=message.payload.query,
+                    variable_values=message.payload.variables,
+                    context_value=context,
+                    root_value=root_value,
+                    operation_name=message.payload.operationName,
+                )
+
+            result_source = get_result_source()
+
+        # Handle initial validation errors
         if isinstance(result_source, GraphQLExecutionResult):
             assert result_source.errors
             payload = [format_graphql_error(result_source.errors[0])]
@@ -219,9 +200,37 @@ async def handle_streaming_operation(self, message: SubscribeMessage) -> None:
             self.schema.process_errors(result_source.errors)
             return
 
-        handler = self.handle_async_results(result_source, message.id)
+        # Create task to handle this subscription, reserve the operation ID
         self.subscriptions[message.id] = result_source
-        self.tasks[message.id] = asyncio.create_task(handler)
+        self.tasks[message.id] = asyncio.create_task(
+            self.operation_task(result_source, message.id)
+        )
+
+    async def operation_task(
+        self, result_source: AsyncGenerator, operation_id: str
+    ) -> None:
+        """
+        Operation task top level method.  Cleans up and de-registers the operation
+        once it is done.
+        """
+        try:
+            await self.handle_async_results(result_source, operation_id)
+        except BaseException:  # pragma: no cover
+            # cleanup in case of something really unexpected
+            del self.subscriptions[operation_id]
+            del self.tasks[operation_id]
+            raise
+        else:
+            # de-register the operation _before_ sending the `Complete` message
+            # to make the `operation_id` immediately available for re-use
+            del self.subscriptions[operation_id]
+            del self.tasks[operation_id]
+            await self.send_message(CompleteMessage(id=operation_id))
+        finally:
+            # add this task to a list to be reaped later
+            task = asyncio.current_task()
+            assert task is not None
+            self.completed_tasks.append(task)
 
     async def handle_async_results(
         self,
@@ -253,8 +262,6 @@ async def handle_async_results(
             self.schema.process_errors([error])
             return
 
-        await self.send_message(CompleteMessage(id=operation_id))
-
     async def handle_complete(self, message: CompleteMessage) -> None:
         await self.cleanup_operation(operation_id=message.id)
 
@@ -266,10 +273,22 @@ async def send_message(self, message: GraphQLTransportMessage) -> None:
         await self.send_json(data)
 
     async def cleanup_operation(self, operation_id: str) -> None:
-        await self.subscriptions[operation_id].aclose()
-        del self.subscriptions[operation_id]
-
-        self.tasks[operation_id].cancel()
+        if operation_id not in self.subscriptions:
+            return
+        generator = self.subscriptions.pop(operation_id)
+        task = self.tasks.pop(operation_id)
+        # since python 3.8, generators cannot be reliably closed
+        with suppress(RuntimeError):
+            await generator.aclose()
+        task.cancel()
         with suppress(BaseException):
-            await self.tasks[operation_id]
-        del self.tasks[operation_id]
+            await task
+
+    async def reap_completed_tasks(self) -> None:
+        """
+        Await tasks that have completed
+        """
+        tasks, self.completed_tasks = self.completed_tasks, []
+        for task in tasks:
+            with suppress(BaseException):
+                await task


Test patch:
diff --git a/tests/aiohttp/schema.py b/tests/aiohttp/schema.py
--- a/tests/aiohttp/schema.py
+++ b/tests/aiohttp/schema.py
@@ -41,10 +41,20 @@ class Query:
     def hello(self, name: typing.Optional[str] = None) -> str:
         return f"Hello {name or 'world'}"
 
+    @strawberry.field
+    async def async_hello(self, name: str, delay: float = 0) -> str:
+        await asyncio.sleep(delay)
+        return f"Hello {name or 'world'}"
+
     @strawberry.field(permission_classes=[AlwaysFailPermission])
     def always_fail(self) -> typing.Optional[str]:
         return "Hey"
 
+    @strawberry.field
+    async def exception(self, message: str) -> str:
+        raise ValueError(message)
+        return message
+
 
 @strawberry.type
 class Mutation:
diff --git a/tests/aiohttp/test_graphql_transport_ws.py b/tests/aiohttp/test_graphql_transport_ws.py
--- a/tests/aiohttp/test_graphql_transport_ws.py
+++ b/tests/aiohttp/test_graphql_transport_ws.py
@@ -285,6 +285,61 @@ async def test_duplicated_operation_ids(aiohttp_client):
         assert data.extra == "Subscriber for sub1 already exists"
 
 
+async def test_reused_operation_ids(aiohttp_client):
+    """
+    Test that an operation id can be re-used after it has been
+    previously used for a completed operation
+    """
+    app = create_app()
+    aiohttp_app_client = await aiohttp_client(app)
+
+    async with aiohttp_app_client.ws_connect(
+        "/graphql", protocols=[GRAPHQL_TRANSPORT_WS_PROTOCOL]
+    ) as ws:
+        await ws.send_json(ConnectionInitMessage().as_dict())
+
+        response = await ws.receive_json()
+        assert response == ConnectionAckMessage().as_dict()
+
+        # Use sub1 as an id for an operation
+        await ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='subscription { echo(message: "Hi") }'
+                ),
+            ).as_dict()
+        )
+
+        response = await ws.receive_json()
+        assert (
+            response
+            == NextMessage(id="sub1", payload={"data": {"echo": "Hi"}}).as_dict()
+        )
+
+        response = await ws.receive_json()
+        assert response == CompleteMessage(id="sub1").as_dict()
+
+        # operation is now complete.  Create a new operation using
+        # the same ID
+        await ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='subscription { echo(message: "Hi") }'
+                ),
+            ).as_dict()
+        )
+
+        response = await ws.receive_json()
+        assert (
+            response
+            == NextMessage(id="sub1", payload={"data": {"echo": "Hi"}}).as_dict()
+        )
+        await ws.close()
+        assert ws.closed
+
+
 async def test_simple_subscription(aiohttp_client):
     app = create_app()
     aiohttp_app_client = await aiohttp_client(app)
@@ -545,6 +600,99 @@ async def test_single_result_query_operation(aiohttp_client):
         assert ws.closed
 
 
+async def test_single_result_query_operation_async(aiohttp_client):
+    """
+    Test a single result query operation on an
+    `async` method in the schema, including an artificial
+    async delay
+    """
+    app = create_app()
+    aiohttp_app_client = await aiohttp_client(app)
+
+    async with aiohttp_app_client.ws_connect(
+        "/graphql", protocols=[GRAPHQL_TRANSPORT_WS_PROTOCOL]
+    ) as ws:
+        await ws.send_json(ConnectionInitMessage().as_dict())
+
+        response = await ws.receive_json()
+        assert response == ConnectionAckMessage().as_dict()
+
+        await ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='query { asyncHello(name: "Dolly", delay:0.01)}'
+                ),
+            ).as_dict()
+        )
+
+        response = await ws.receive_json()
+        assert (
+            response
+            == NextMessage(
+                id="sub1", payload={"data": {"asyncHello": "Hello Dolly"}}
+            ).as_dict()
+        )
+
+        response = await ws.receive_json()
+        assert response == CompleteMessage(id="sub1").as_dict()
+
+        await ws.close()
+        assert ws.closed
+
+
+async def test_single_result_query_operation_overlapped(aiohttp_client):
+    """
+    Test that two single result queries can be in flight at the same time,
+    just like regular queries.  Start two queries with separate ids. The
+    first query has a delay, so we expect the response to the second
+    query to be delivered first.
+    """
+    app = create_app()
+    aiohttp_app_client = await aiohttp_client(app)
+
+    async with aiohttp_app_client.ws_connect(
+        "/graphql", protocols=[GRAPHQL_TRANSPORT_WS_PROTOCOL]
+    ) as ws:
+        await ws.send_json(ConnectionInitMessage().as_dict())
+
+        response = await ws.receive_json()
+        assert response == ConnectionAckMessage().as_dict()
+
+        # first query
+        await ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='query { asyncHello(name: "Dolly", delay:1)}'
+                ),
+            ).as_dict()
+        )
+        # second query
+        await ws.send_json(
+            SubscribeMessage(
+                id="sub2",
+                payload=SubscribeMessagePayload(
+                    query='query { asyncHello(name: "Dolly", delay:0)}'
+                ),
+            ).as_dict()
+        )
+
+        # we expect the response to the second query to arrive first
+        response = await ws.receive_json()
+        assert (
+            response
+            == NextMessage(
+                id="sub2", payload={"data": {"asyncHello": "Hello Dolly"}}
+            ).as_dict()
+        )
+        response = await ws.receive_json()
+        assert response == CompleteMessage(id="sub2").as_dict()
+
+        await ws.close()
+        assert ws.closed
+
+
 async def test_single_result_mutation_operation(aiohttp_client):
     app = create_app()
     aiohttp_app_client = await aiohttp_client(app)
@@ -682,3 +830,128 @@ async def test_single_result_operation_error(aiohttp_client):
 
         await ws.close()
         assert ws.closed
+
+
+async def test_single_result_operation_exception(aiohttp_client):
+    """
+    Test that single-result-operations which raise exceptions
+    behave in the same way as streaming operations
+    """
+    app = create_app()
+    aiohttp_app_client = await aiohttp_client(app)
+
+    async with aiohttp_app_client.ws_connect(
+        "/graphql", protocols=[GRAPHQL_TRANSPORT_WS_PROTOCOL]
+    ) as ws:
+        await ws.send_json(ConnectionInitMessage().as_dict())
+
+        response = await ws.receive_json()
+        assert response == ConnectionAckMessage().as_dict()
+
+        await ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='query { exception(message: "bummer") }',
+                ),
+            ).as_dict()
+        )
+
+        response = await ws.receive_json()
+        assert response["type"] == ErrorMessage.type
+        assert response["id"] == "sub1"
+        assert len(response["payload"]) == 1
+        assert response["payload"][0].get("path") == ["exception"]
+        assert response["payload"][0]["message"] == "bummer"
+
+
+async def test_single_result_duplicate_ids_sub(aiohttp_client):
+    """
+    Test that single-result-operations and streaming operations
+    share the same ID namespace.  Start a regular subscription,
+    then issue a single-result operation with same ID and expect an
+    error due to already existing ID
+    """
+    app = create_app()
+    aiohttp_app_client = await aiohttp_client(app)
+
+    async with aiohttp_app_client.ws_connect(
+        "/graphql", protocols=[GRAPHQL_TRANSPORT_WS_PROTOCOL]
+    ) as ws:
+        await ws.send_json(ConnectionInitMessage().as_dict())
+
+        response = await ws.receive_json()
+        assert response == ConnectionAckMessage().as_dict()
+
+        # regular subscription
+        await ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='subscription { echo(message: "Hi", delay: 5) }'
+                ),
+            ).as_dict()
+        )
+        # single result subscription with duplicate id
+        await ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query="query { hello }",
+                ),
+            ).as_dict()
+        )
+
+        data = await ws.receive(timeout=2)
+        assert ws.closed
+        assert ws.close_code == 4409
+        assert data.extra == "Subscriber for sub1 already exists"
+
+        await ws.close()
+        assert ws.closed
+
+
+async def test_single_result_duplicate_ids_query(aiohttp_client):
+    """
+    Test that single-result-operations don't allow duplicate
+    IDs for two asynchronous queries.  Issue one async query
+    with delay, then another with same id.  Expect error.
+    """
+    app = create_app()
+    aiohttp_app_client = await aiohttp_client(app)
+
+    async with aiohttp_app_client.ws_connect(
+        "/graphql", protocols=[GRAPHQL_TRANSPORT_WS_PROTOCOL]
+    ) as ws:
+        await ws.send_json(ConnectionInitMessage().as_dict())
+
+        response = await ws.receive_json()
+        assert response == ConnectionAckMessage().as_dict()
+
+        # single result subscription 1
+        await ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='query { asyncHello(name: "Hi", delay: 5) }'
+                ),
+            ).as_dict()
+        )
+        # single result subscription with duplicate id
+        await ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query="query { hello }",
+                ),
+            ).as_dict()
+        )
+
+        # We expect the remote to close the socket due to duplicate ID in use
+        data = await ws.receive(timeout=2)
+        assert ws.closed
+        assert ws.close_code == 4409
+        assert data.extra == "Subscriber for sub1 already exists"
+
+        await ws.close()
+        assert ws.closed
diff --git a/tests/asgi/schema.py b/tests/asgi/schema.py
--- a/tests/asgi/schema.py
+++ b/tests/asgi/schema.py
@@ -43,6 +43,11 @@ class Query:
     def hello(self, name: typing.Optional[str] = None) -> str:
         return f"Hello {name or 'world'}"
 
+    @strawberry.field
+    async def async_hello(self, name: str, delay: float = 0) -> str:
+        await asyncio.sleep(delay)
+        return f"Hello {name or 'world'}"
+
     @strawberry.field(permission_classes=[AlwaysFailPermission])
     def always_fail(self) -> Optional[str]:
         return "Hey"
@@ -51,6 +56,11 @@ def always_fail(self) -> Optional[str]:
     def root_name(root) -> str:
         return type(root).__name__
 
+    @strawberry.field
+    async def exception(self, message: str) -> str:
+        raise ValueError(message)
+        return message
+
 
 @strawberry.type
 class Mutation:
diff --git a/tests/asgi/test_graphql_transport_ws.py b/tests/asgi/test_graphql_transport_ws.py
--- a/tests/asgi/test_graphql_transport_ws.py
+++ b/tests/asgi/test_graphql_transport_ws.py
@@ -220,6 +220,56 @@ def test_duplicated_operation_ids(test_client):
         assert data["code"] == 4409
 
 
+def test_reused_operation_ids(test_client):
+    """
+    Test that an operation id can be re-used after it has been
+    previously used for a completed operation
+    """
+    with test_client.websocket_connect(
+        "/graphql", [GRAPHQL_TRANSPORT_WS_PROTOCOL]
+    ) as ws:
+        ws.send_json(ConnectionInitMessage().as_dict())
+
+        response = ws.receive_json()
+        assert response == ConnectionAckMessage().as_dict()
+
+        # Use sub1 as an id for an operation
+        ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='subscription { echo(message: "Hi") }'
+                ),
+            ).as_dict()
+        )
+
+        response = ws.receive_json()
+        assert (
+            response
+            == NextMessage(id="sub1", payload={"data": {"echo": "Hi"}}).as_dict()
+        )
+
+        response = ws.receive_json()
+        assert response == CompleteMessage(id="sub1").as_dict()
+
+        # operation is now complete.  Create a new operation using
+        # the same ID
+        ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='subscription { echo(message: "Hi") }'
+                ),
+            ).as_dict()
+        )
+
+        response = ws.receive_json()
+        assert (
+            response
+            == NextMessage(id="sub1", payload={"data": {"echo": "Hi"}}).as_dict()
+        )
+
+
 def test_simple_subscription(test_client):
     with test_client.websocket_connect("/", [GRAPHQL_TRANSPORT_WS_PROTOCOL]) as ws:
         ws.send_json(ConnectionInitMessage().as_dict())
@@ -438,6 +488,87 @@ def test_single_result_query_operation(test_client):
         assert response == CompleteMessage(id="sub1").as_dict()
 
 
+def test_single_result_query_operation_async(test_client):
+    """
+    Test a single result query operation on an
+    `async` method in the schema, including an artificial
+    async delay
+    """
+    with test_client.websocket_connect(
+        "/graphql", [GRAPHQL_TRANSPORT_WS_PROTOCOL]
+    ) as ws:
+        ws.send_json(ConnectionInitMessage().as_dict())
+
+        response = ws.receive_json()
+        assert response == ConnectionAckMessage().as_dict()
+
+        ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='query { asyncHello(name: "Dolly", delay:0.01)}'
+                ),
+            ).as_dict()
+        )
+
+        response = ws.receive_json()
+        assert (
+            response
+            == NextMessage(
+                id="sub1", payload={"data": {"asyncHello": "Hello Dolly"}}
+            ).as_dict()
+        )
+
+        response = ws.receive_json()
+        assert response == CompleteMessage(id="sub1").as_dict()
+
+
+def test_single_result_query_operation_overlapped(test_client):
+    """
+    Test that two single result queries can be in flight at the same time,
+    just like regular queries.  Start two queries with separate ids. The
+    first query has a delay, so we expect the response to the second
+    query to be delivered first.
+    """
+    with test_client.websocket_connect(
+        "/graphql", [GRAPHQL_TRANSPORT_WS_PROTOCOL]
+    ) as ws:
+        ws.send_json(ConnectionInitMessage().as_dict())
+
+        response = ws.receive_json()
+        assert response == ConnectionAckMessage().as_dict()
+
+        # first query
+        ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='query { asyncHello(name: "Dolly", delay:1)}'
+                ),
+            ).as_dict()
+        )
+        # second query
+        ws.send_json(
+            SubscribeMessage(
+                id="sub2",
+                payload=SubscribeMessagePayload(
+                    query='query { asyncHello(name: "Dolly", delay:0)}'
+                ),
+            ).as_dict()
+        )
+
+        # we expect the response to the second query to arrive first
+        response = ws.receive_json()
+        assert (
+            response
+            == NextMessage(
+                id="sub2", payload={"data": {"asyncHello": "Hello Dolly"}}
+            ).as_dict()
+        )
+        response = ws.receive_json()
+        assert response == CompleteMessage(id="sub2").as_dict()
+
+
 def test_single_result_mutation_operation(test_client):
     with test_client.websocket_connect(
         "/graphql", [GRAPHQL_TRANSPORT_WS_PROTOCOL]
@@ -553,3 +684,111 @@ def test_single_result_operation_error(test_client):
         assert response["id"] == "sub1"
         assert len(response["payload"]) == 1
         assert response["payload"][0]["message"] == "You are not authorized"
+
+
+def test_single_result_operation_exception(test_client):
+    """
+    Test that single-result-operations which raise exceptions
+    behave in the same way as streaming operations
+    """
+    with test_client.websocket_connect(
+        "/graphql", [GRAPHQL_TRANSPORT_WS_PROTOCOL]
+    ) as ws:
+        ws.send_json(ConnectionInitMessage().as_dict())
+
+        response = ws.receive_json()
+        assert response == ConnectionAckMessage().as_dict()
+
+        ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='query { exception(message: "bummer") }',
+                ),
+            ).as_dict()
+        )
+
+        response = ws.receive_json()
+        assert response["type"] == ErrorMessage.type
+        assert response["id"] == "sub1"
+        assert len(response["payload"]) == 1
+        assert response["payload"][0].get("path") == ["exception"]
+        assert response["payload"][0]["message"] == "bummer"
+
+
+def test_single_result_duplicate_ids_sub(test_client):
+    """
+    Test that single-result-operations and streaming operations
+    share the same ID namespace.  Start a regular subscription,
+    then issue a single-result operation with same ID and expect an
+    error due to already existing ID
+    """
+    with test_client.websocket_connect(
+        "/graphql", [GRAPHQL_TRANSPORT_WS_PROTOCOL]
+    ) as ws:
+        ws.send_json(ConnectionInitMessage().as_dict())
+
+        response = ws.receive_json()
+        assert response == ConnectionAckMessage().as_dict()
+
+        # regular subscription
+        ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='subscription { echo(message: "Hi", delay: 5) }'
+                ),
+            ).as_dict()
+        )
+        # single result subscription with duplicate id
+        ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query="query { hello }",
+                ),
+            ).as_dict()
+        )
+
+        data = ws.receive()
+        assert data["type"] == "websocket.close"
+        assert data["code"] == 4409
+
+
+def test_single_result_duplicate_ids_query(test_client):
+    """
+    Test that single-result-operations don't allow duplicate
+    IDs for two asynchronous queries.  Issue one async query
+    with delay, then another with same id.  Expect error.
+    """
+    with test_client.websocket_connect(
+        "/graphql", [GRAPHQL_TRANSPORT_WS_PROTOCOL]
+    ) as ws:
+        ws.send_json(ConnectionInitMessage().as_dict())
+
+        response = ws.receive_json()
+        assert response == ConnectionAckMessage().as_dict()
+
+        # single result subscription 1
+        ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='query { asyncHello(name: "Hi", delay: 5) }'
+                ),
+            ).as_dict()
+        )
+        # single result subscription with duplicate id
+        ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query="query { hello }",
+                ),
+            ).as_dict()
+        )
+
+        # We expect the remote to close the socket due to duplicate ID in use
+        data = ws.receive()
+        assert data["type"] == "websocket.close"
+        assert data["code"] == 4409
diff --git a/tests/fastapi/schema.py b/tests/fastapi/schema.py
--- a/tests/fastapi/schema.py
+++ b/tests/fastapi/schema.py
@@ -43,6 +43,11 @@ class Query:
     def hello(self, name: typing.Optional[str] = None) -> str:
         return f"Hello {name or 'world'}"
 
+    @strawberry.field
+    async def async_hello(self, name: str, delay: float = 0) -> str:
+        await asyncio.sleep(delay)
+        return f"Hello {name or 'world'}"
+
     @strawberry.field(permission_classes=[AlwaysFailPermission])
     def always_fail(self) -> Optional[str]:
         return "Hey"
@@ -51,6 +56,11 @@ def always_fail(self) -> Optional[str]:
     def root_name(root) -> str:
         return type(root).__name__
 
+    @strawberry.field
+    async def exception(self, message: str) -> str:
+        raise ValueError(message)
+        return message
+
 
 @strawberry.type
 class Mutation:
diff --git a/tests/fastapi/test_graphql_transport_ws.py b/tests/fastapi/test_graphql_transport_ws.py
--- a/tests/fastapi/test_graphql_transport_ws.py
+++ b/tests/fastapi/test_graphql_transport_ws.py
@@ -244,6 +244,56 @@ def test_duplicated_operation_ids(test_client):
         assert data["code"] == 4409
 
 
+def test_reused_operation_ids(test_client):
+    """
+    Test that an operation id can be re-used after it has been
+    previously used for a completed operation
+    """
+    with test_client.websocket_connect(
+        "/graphql", [GRAPHQL_TRANSPORT_WS_PROTOCOL]
+    ) as ws:
+        ws.send_json(ConnectionInitMessage().as_dict())
+
+        response = ws.receive_json()
+        assert response == ConnectionAckMessage().as_dict()
+
+        # Use sub1 as an id for an operation
+        ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='subscription { echo(message: "Hi") }'
+                ),
+            ).as_dict()
+        )
+
+        response = ws.receive_json()
+        assert (
+            response
+            == NextMessage(id="sub1", payload={"data": {"echo": "Hi"}}).as_dict()
+        )
+
+        response = ws.receive_json()
+        assert response == CompleteMessage(id="sub1").as_dict()
+
+        # operation is now complete.  Create a new operation using
+        # the same ID
+        ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='subscription { echo(message: "Hi") }'
+                ),
+            ).as_dict()
+        )
+
+        response = ws.receive_json()
+        assert (
+            response
+            == NextMessage(id="sub1", payload={"data": {"echo": "Hi"}}).as_dict()
+        )
+
+
 def test_simple_subscription(test_client):
     with test_client.websocket_connect(
         "/graphql", [GRAPHQL_TRANSPORT_WS_PROTOCOL]
@@ -474,6 +524,87 @@ def test_single_result_query_operation(test_client):
         assert response == CompleteMessage(id="sub1").as_dict()
 
 
+def test_single_result_query_operation_async(test_client):
+    """
+    Test a single result query operation on an
+    `async` method in the schema, including an artificial
+    async delay
+    """
+    with test_client.websocket_connect(
+        "/graphql", [GRAPHQL_TRANSPORT_WS_PROTOCOL]
+    ) as ws:
+        ws.send_json(ConnectionInitMessage().as_dict())
+
+        response = ws.receive_json()
+        assert response == ConnectionAckMessage().as_dict()
+
+        ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='query { asyncHello(name: "Dolly", delay:0.01)}'
+                ),
+            ).as_dict()
+        )
+
+        response = ws.receive_json()
+        assert (
+            response
+            == NextMessage(
+                id="sub1", payload={"data": {"asyncHello": "Hello Dolly"}}
+            ).as_dict()
+        )
+
+        response = ws.receive_json()
+        assert response == CompleteMessage(id="sub1").as_dict()
+
+
+def test_single_result_query_operation_overlapped(test_client):
+    """
+    Test that two single result queries can be in flight at the same time,
+    just like regular queries.  Start two queries with separate ids. The
+    first query has a delay, so we expect the response to the second
+    query to be delivered first.
+    """
+    with test_client.websocket_connect(
+        "/graphql", [GRAPHQL_TRANSPORT_WS_PROTOCOL]
+    ) as ws:
+        ws.send_json(ConnectionInitMessage().as_dict())
+
+        response = ws.receive_json()
+        assert response == ConnectionAckMessage().as_dict()
+
+        # first query
+        ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='query { asyncHello(name: "Dolly", delay:1)}'
+                ),
+            ).as_dict()
+        )
+        # second query
+        ws.send_json(
+            SubscribeMessage(
+                id="sub2",
+                payload=SubscribeMessagePayload(
+                    query='query { asyncHello(name: "Dolly", delay:0)}'
+                ),
+            ).as_dict()
+        )
+
+        # we expect the response to the second query to arrive first
+        response = ws.receive_json()
+        assert (
+            response
+            == NextMessage(
+                id="sub2", payload={"data": {"asyncHello": "Hello Dolly"}}
+            ).as_dict()
+        )
+        response = ws.receive_json()
+        assert response == CompleteMessage(id="sub2").as_dict()
+
+
 def test_single_result_mutation_operation(test_client):
     with test_client.websocket_connect(
         "/graphql", [GRAPHQL_TRANSPORT_WS_PROTOCOL]
@@ -589,3 +720,111 @@ def test_single_result_operation_error(test_client):
         assert response["id"] == "sub1"
         assert len(response["payload"]) == 1
         assert response["payload"][0]["message"] == "You are not authorized"
+
+
+def test_single_result_operation_exception(test_client):
+    """
+    Test that single-result-operations which raise exceptions
+    behave in the same way as streaming operations
+    """
+    with test_client.websocket_connect(
+        "/graphql", [GRAPHQL_TRANSPORT_WS_PROTOCOL]
+    ) as ws:
+        ws.send_json(ConnectionInitMessage().as_dict())
+
+        response = ws.receive_json()
+        assert response == ConnectionAckMessage().as_dict()
+
+        ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='query { exception(message: "bummer") }',
+                ),
+            ).as_dict()
+        )
+
+        response = ws.receive_json()
+        assert response["type"] == ErrorMessage.type
+        assert response["id"] == "sub1"
+        assert len(response["payload"]) == 1
+        assert response["payload"][0].get("path") == ["exception"]
+        assert response["payload"][0]["message"] == "bummer"
+
+
+def test_single_result_duplicate_ids_sub(test_client):
+    """
+    Test that single-result-operations and streaming operations
+    share the same ID namespace.  Start a regular subscription,
+    then issue a single-result operation with same ID and expect an
+    error due to already existing ID
+    """
+    with test_client.websocket_connect(
+        "/graphql", [GRAPHQL_TRANSPORT_WS_PROTOCOL]
+    ) as ws:
+        ws.send_json(ConnectionInitMessage().as_dict())
+
+        response = ws.receive_json()
+        assert response == ConnectionAckMessage().as_dict()
+
+        # regular subscription
+        ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='subscription { echo(message: "Hi", delay: 5) }'
+                ),
+            ).as_dict()
+        )
+        # single result subscription with duplicate id
+        ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query="query { hello }",
+                ),
+            ).as_dict()
+        )
+
+        data = ws.receive()
+        assert data["type"] == "websocket.close"
+        assert data["code"] == 4409
+
+
+def test_single_result_duplicate_ids_query(test_client):
+    """
+    Test that single-result-operations don't allow duplicate
+    IDs for two asynchronous queries.  Issue one async query
+    with delay, then another with same id.  Expect error.
+    """
+    with test_client.websocket_connect(
+        "/graphql", [GRAPHQL_TRANSPORT_WS_PROTOCOL]
+    ) as ws:
+        ws.send_json(ConnectionInitMessage().as_dict())
+
+        response = ws.receive_json()
+        assert response == ConnectionAckMessage().as_dict()
+
+        # single result subscription 1
+        ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query='query { asyncHello(name: "Hi", delay: 5) }'
+                ),
+            ).as_dict()
+        )
+        # single result subscription with duplicate id
+        ws.send_json(
+            SubscribeMessage(
+                id="sub1",
+                payload=SubscribeMessagePayload(
+                    query="query { hello }",
+                ),
+            ).as_dict()
+        )
+
+        # We expect the remote to close the socket due to duplicate ID in use
+        data = ws.receive()
+        assert data["type"] == "websocket.close"
+        assert data["code"] == 4409