Repository: Pycord-Development/pycord

Issue:
Race condition in InteractionResponse
### Summary

There is a race condition in InteractionResponse

### Reproduction Steps

Running two task that will send a response to a Interaction will end in unexpected error.
Second Task will Raise NotFound Exception instead of `InteractionResponded` error.
This is due to a race condition in the libarry.

And because of this ApplicationContext.respond is also broken.

### Minimal Reproducible Code

```python
import asyncio

import discord.ext.commands

bot = discord.ext.commands.Bot()


@bot.slash_command(guild_ids=[...])
async def foo(ctx):
    async def respond():
        await ctx.respond("Race condition")

    asyncio.create_task(respond())
    asyncio.create_task(respond())


@bot.event
async def on_ready():
    print("Online")



bot.run("Token")
```


### Expected Results

Command should exit with `InteractionResponded` or better ApplicationContext.respond should switch to followup.

### Actual Results

NotFound HTTP exeption is raised.

`404`

### Intents

Default

### System Information

py-cord 2.0.0b4 master

Python 3.9 interpreter

OS Name	Microsoft Windows 10 Home
Version	10.0.19041 Build 19041


### Checklist

- [X] I have searched the open issues for duplicates.
- [X] I have shown the entire traceback, if possible.
- [X] I have removed my token from display, if visible.

### Additional Context

Started working on a fix using asyncio.Lock


Discussion:
Your System Information should be your OS's information, py-cord's version info and more information if any. Also, can you show us the full exception traceback?
This happens with me also, when I run code in VS code python debugger using some breakpoints.
> Your System Information should be your OS's information, py-cord's version info and more information if any. Also, can you show us the full exception traceback?

I'll recreate the bug when I'm in school.
And will update the issue with the requested information.
> Your System Information should be your OS's information, py-cord's version info and more information if any. Also, can you show us the full exception traceback?

## Traceback

Task exception was never retrieved
future: <Task finished name='Task-25' coro=<foo.<locals>.respond() done, defined at C:\Users\redacted\PycharmProjects\pycord\tests\bot_test.py:10> exception=NotFound('404 Not Found (error code: 10062): Unknown interaction')>
Traceback (most recent call last):
  File "C:\Users\redacted\PycharmProjects\pycord\tests\bot_test.py", line 11, in respond
    await ctx.respond("Race condition")
  File "C:\Users\redacted\PycharmProjects\pycord\discord\interactions.py", line 615, in send_message
    await adapter.create_interaction_response(
  File "C:\Users\redacted\PycharmProjects\pycord\discord\webhook\async_.py", line 192, in request
    raise NotFound(response, data)
discord.errors.NotFound: 404 Not Found (error code: 10062): Unknown interaction

Resolving patch:
diff --git a/discord/commands/context.py b/discord/commands/context.py
--- a/discord/commands/context.py
+++ b/discord/commands/context.py
@@ -220,14 +220,15 @@ def send_modal(self) -> Callable[..., Awaitable[Interaction]]:
         """Sends a modal dialog to the user who invoked the interaction."""
         return self.interaction.response.send_modal
 
-    @property
-    def respond(self) -> Callable[..., Awaitable[Union[Interaction, WebhookMessage]]]:
-        """Callable[..., Union[:class:`~.Interaction`, :class:`~.Webhook`]]: Sends either a response
-        or a followup response depending on if the interaction has been responded to yet or not."""
-        if not self.interaction.response.is_done():
-            return self.interaction.response.send_message  # self.response
-        else:
-            return self.followup.send  # self.send_followup
+    async def respond(self, *args, **kwargs) -> Union[Interaction, WebhookMessage]:
+        """Sends either a response or a followup response depending if the interaction has been responded to yet or not."""
+        try:
+            if not self.interaction.response.is_done():
+                return await self.interaction.response.send_message(*args, **kwargs)  # self.response
+            else:
+                return await self.followup.send(*args, **kwargs)  # self.send_followup
+        except discord.errors.InteractionResponded:
+            return await self.followup.send(*args, **kwargs)
 
     @property
     def send_response(self) -> Callable[..., Awaitable[Interaction]]:
diff --git a/discord/interactions.py b/discord/interactions.py
--- a/discord/interactions.py
+++ b/discord/interactions.py
@@ -27,7 +27,8 @@
 
 from __future__ import annotations
 
-from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
+import asyncio
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, Coroutine
 
 from . import utils
 from .channel import ChannelType, PartialMessageable
@@ -436,11 +437,13 @@ class InteractionResponse:
     __slots__: Tuple[str, ...] = (
         "_responded",
         "_parent",
+        "_response_lock",
     )
 
     def __init__(self, parent: Interaction):
         self._parent: Interaction = parent
         self._responded: bool = False
+        self._response_lock = asyncio.Lock()
 
     def is_done(self) -> bool:
         """:class:`bool`: Indicates whether an interaction response has been done before.
@@ -489,12 +492,14 @@ async def defer(self, *, ephemeral: bool = False) -> None:
 
         if defer_type:
             adapter = async_context.get()
-            await adapter.create_interaction_response(
-                parent.id,
-                parent.token,
-                session=parent._session,
-                type=defer_type,
-                data=data,
+            await self._locked_response(
+                adapter.create_interaction_response(
+                    parent.id,
+                    parent.token,
+                    session=parent._session,
+                    type=defer_type,
+                    data=data,
+                )
             )
             self._responded = True
 
@@ -518,11 +523,13 @@ async def pong(self) -> None:
         parent = self._parent
         if parent.type is InteractionType.ping:
             adapter = async_context.get()
-            await adapter.create_interaction_response(
-                parent.id,
-                parent.token,
-                session=parent._session,
-                type=InteractionResponseType.pong.value,
+            await self._locked_response(
+                adapter.create_interaction_response(
+                    parent.id,
+                    parent.token,
+                    session=parent._session,
+                    type=InteractionResponseType.pong.value,
+                )
             )
             self._responded = True
 
@@ -638,13 +645,15 @@ async def send_message(
         parent = self._parent
         adapter = async_context.get()
         try:
-            await adapter.create_interaction_response(
-                parent.id,
-                parent.token,
-                session=parent._session,
-                type=InteractionResponseType.channel_message.value,
-                data=payload,
-                files=files,
+            await self._locked_response(
+                adapter.create_interaction_response(
+                    parent.id,
+                    parent.token,
+                    session=parent._session,
+                    type=InteractionResponseType.channel_message.value,
+                    data=payload,
+                    files=files,
+                )
             )
         finally:
             if files:
@@ -734,12 +743,14 @@ async def edit_message(
             state.prevent_view_updates_for(message_id)
             payload["components"] = [] if view is None else view.to_components()
         adapter = async_context.get()
-        await adapter.create_interaction_response(
-            parent.id,
-            parent.token,
-            session=parent._session,
-            type=InteractionResponseType.message_update.value,
-            data=payload,
+        await self._locked_response(
+            adapter.create_interaction_response(
+                parent.id,
+                parent.token,
+                session=parent._session,
+                type=InteractionResponseType.message_update.value,
+                data=payload,
+            )
         )
 
         if view and not view.is_finished():
@@ -780,12 +791,14 @@ async def send_autocomplete_result(
         payload = {"choices": [c.to_dict() for c in choices]}
 
         adapter = async_context.get()
-        await adapter.create_interaction_response(
-            parent.id,
-            parent.token,
-            session=parent._session,
-            type=InteractionResponseType.auto_complete_result.value,
-            data=payload,
+        await self._locked_response(
+            adapter.create_interaction_response(
+                parent.id,
+                parent.token,
+                session=parent._session,
+                type=InteractionResponseType.auto_complete_result.value,
+                data=payload,
+            )
         )
 
         self._responded = True
@@ -812,17 +825,40 @@ async def send_modal(self, modal: Modal) -> Interaction:
 
         payload = modal.to_dict()
         adapter = async_context.get()
-        await adapter.create_interaction_response(
-            self._parent.id,
-            self._parent.token,
-            session=self._parent._session,
-            type=InteractionResponseType.modal.value,
-            data=payload,
+        await self._locked_response(
+            adapter.create_interaction_response(
+                self._parent.id,
+                self._parent.token,
+                session=self._parent._session,
+                type=InteractionResponseType.modal.value,
+                data=payload,
+            )
         )
         self._responded = True
         self._parent._state.store_modal(modal, self._parent.user.id)
         return self._parent
 
+    async def _locked_response(self, coro: Coroutine[Any]):
+        """|coro|
+
+        Wraps a response and makes sure that it's locked while executing.
+
+        Parameters
+        -----------
+        coro: Coroutine[Any]
+            The coroutine to wrap.
+
+        Raises
+        -------
+        InteractionResponded
+            This interaction has already been responded to before.
+        """
+        async with self._response_lock:
+            if self.is_done():
+                coro.close()  # cleanup unawaited coroutine
+                raise InteractionResponded(self._parent)
+            await coro
+
 
 class _InteractionMessageState:
     __slots__ = ("_parent", "_interaction")


Test patch: