diff --git a/.github/workflows/cuda-gpu-ci.yml b/.github/workflows/cuda-gpu-ci.yml
new file mode 100644
index 0000000000000..d962145cfbbc7
--- /dev/null
+++ b/.github/workflows/cuda-gpu-ci.yml
@@ -0,0 +1,47 @@
+name: CUDA GPU
+on:
+  workflow_dispatch:
+    inputs:
+      pr_id:
+        description: Test the contents of this Pull Request
+        required: true
+
+permissions: read-all
+
+jobs:
+  tests:
+    runs-on:
+      group: cuda-gpu-runner-group
+    name: Run Array API unit tests
+    steps:
+      - uses: actions/setup-python@v4
+        with:
+          python-version: '3.12'
+      - name: Checkout main repository
+        uses: actions/checkout@v2
+      - name: Checkout a particular Pull Request
+        if: inputs.pr_id
+        env:
+          PR_ID: ${{ inputs.pr_id }}
+          GH_TOKEN: ${{ github.token }}
+        run: |
+          gh pr checkout ${{ env.PR_ID }}
+      - name: Cache conda environment
+        id: cache-conda
+        uses: actions/cache@v3
+        with:
+          path: ~/conda
+          key: ${{ runner.os }}-build-${{ hashFiles('build_tools/github/create_gpu_environment.sh') }}-${{ hashFiles('build_tools/github/pylatest_conda_forge_cuda_array-api_linux-64_conda.lock') }}
+      - name: Install miniforge
+        if: ${{ steps.cache-conda.outputs.cache-hit != 'true' }}
+        run: bash build_tools/github/create_gpu_environment.sh
+      - name: Install scikit-learn
+        run: |
+          source "${HOME}/conda/etc/profile.d/conda.sh"
+          conda activate sklearn
+          pip install --verbose --no-build-isolation --config-settings editable-verbose=true --editable .
+      - name: Run array API tests
+        run: |
+          source "${HOME}/conda/etc/profile.d/conda.sh"
+          conda activate sklearn
+          pytest -k 'array_api'
diff --git a/.github/workflows/update-lock-files.yml b/.github/workflows/update-lock-files.yml
index 8301b45fa37fa..d07f62a0433a8 100644
--- a/.github/workflows/update-lock-files.yml
+++ b/.github/workflows/update-lock-files.yml
@@ -6,6 +6,10 @@ on:
   schedule:
     - cron: '0 5 * * 1'

+# XXX Set the right permissions, per step??
+# Can we set read only at the global level here and then elevate to write for some steps?
+#permissions: read-all
+
 jobs:
   update_lock_files:
     if: github.repository == 'scikit-learn/scikit-learn'
@@ -25,6 +29,8 @@ jobs:
           - name: cirrus-arm
             update_script_args: "--select-tag arm"
             additional_commit_message: "[cirrus arm]"
+          - name: array API
+            update_script_args: "--select-tag cuda"

     steps:
       - uses: actions/checkout@v4
@@ -56,6 +62,14 @@ jobs:
             ### Note
             If the CI tasks fail, create a new branch based on this PR and add the required fixes to that branch.

+      # The CUDA workflow needs to be triggered explicitly as it uses an expensive runner
+      - name: Trigger additional tests
+        if: steps.cpr.outputs.pull-request-number != '' && matrix.name == 'array API'
+        env:
+          GH_TOKEN: ${{ github.token }}
+        run: |
+          gh workflow run .github/workflows/cuda-gpu-ci.yml -f pr_id=${{steps.cpr.outputs.pull-request-number}}
+
       - name: Check Pull Request
         if: steps.cpr.outputs.pull-request-number != ''
         run: |
diff --git a/build_tools/github/create_gpu_environment.sh b/build_tools/github/create_gpu_environment.sh
new file mode 100644
index 0000000000000..e03fa0691d5b8
--- /dev/null
+++ b/build_tools/github/create_gpu_environment.sh
@@ -0,0 +1,17 @@
+#!/bin/bash
+
+set -e
+set -x
+
+curl -L -O "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh"
+bash Miniforge3-$(uname)-$(uname -m).sh -b -p "${HOME}/conda"
+source "${HOME}/conda/etc/profile.d/conda.sh"
+
+
+# defines the get_dep and show_installed_libraries functions
+source build_tools/shared.sh
+conda activate base
+
+# XXX switch once https://github.com/scikit-learn/scikit-learn/pull/29176 is merged
+conda install -c conda-forge "$(get_dep conda-lock min)" -y
+conda-lock install --name sklearn build_tools/github/pylatest_conda_forge_cuda_array-api_linux-64_conda.lock
diff --git a/build_tools/update_environments_and_lock_files.py b/build_tools/update_environments_and_lock_files.py
index 92d97709386d1..2f6263cdd961d 100644
--- a/build_tools/update_environments_and_lock_files.py
+++ b/build_tools/update_environments_and_lock_files.py
@@ -90,13 +90,33 @@ def remove_from(alist, to_remove):

 build_metadata_list = [
+    {
+        "name": "pylatest_conda_forge_cuda_array-api_linux-64",
+        "type": "conda",
+        "tag": "cuda",
+        "folder": "build_tools/github",
+        "platform": "linux-64",
+        "channels": ["conda-forge", "pytorch", "nvidia"],
+        "conda_dependencies": common_dependencies
+        + [
+            "ccache",
+            # Make sure pytorch comes from the pytorch channel and not conda-forge
+            "pytorch::pytorch",
+            "pytorch-cuda",
+            "polars",
+            "pyarrow",
+            "cupy",
+            "array-api-compat",
+            "array-api-strict",
+        ],
+    },
     {
         "name": "pylatest_conda_forge_mkl_linux-64",
         "type": "conda",
         "tag": "main-ci",
         "folder": "build_tools/azure",
         "platform": "linux-64",
-        "channel": "conda-forge",
+        "channels": ["conda-forge"],
         "conda_dependencies": common_dependencies
         + [
             "ccache",
@@ -118,7 +138,7 @@ def remove_from(alist, to_remove):
         "tag": "main-ci",
         "folder": "build_tools/azure",
         "platform": "osx-64",
-        "channel": "conda-forge",
+        "channels": ["conda-forge"],
         "conda_dependencies": common_dependencies
         + [
             "ccache",
@@ -135,7 +155,7 @@ def remove_from(alist, to_remove):
         "tag": "main-ci",
         "folder": "build_tools/azure",
         "platform": "osx-64",
-        "channel": "defaults",
+        "channels": ["defaults"],
         "conda_dependencies": remove_from(
             common_dependencies, ["cython", "threadpoolctl", "meson-python"]
         )
@@ -157,7 +177,7 @@ def remove_from(alist, to_remove):
         "tag": "main-ci",
         "folder": "build_tools/azure",
         "platform": "linux-64",
-        "channel": "defaults",
+        "channels": ["defaults"],
         "conda_dependencies": remove_from(
             common_dependencies,
             ["pandas", "threadpoolctl", "pip", "ninja", "meson-python"],
@@ -183,7 +203,7 @@ def remove_from(alist, to_remove):
         "tag": "main-ci",
         "folder": "build_tools/azure",
         "platform": "linux-64",
-        "channel": "conda-forge",
+        "channels": ["conda-forge"],
         "conda_dependencies": (
             common_dependencies_without_coverage
             + docstring_test_dependencies
@@ -200,7 +220,7 @@ def remove_from(alist, to_remove):
         "tag": "main-ci",
         "folder": "build_tools/azure",
         "platform": "linux-64",
-        "channel": "defaults",
+        "channels": ["defaults"],
         "conda_dependencies": ["python", "ccache"],
         "pip_dependencies": (
             remove_from(common_dependencies, ["python", "blas", "pip"])
@@ -217,7 +237,7 @@ def remove_from(alist, to_remove):
         "tag": "scipy-dev",
         "folder": "build_tools/azure",
         "platform": "linux-64",
-        "channel": "defaults",
+        "channels": ["defaults"],
         "conda_dependencies": ["python", "ccache"],
         "pip_dependencies": (
             remove_from(
@@ -251,7 +271,7 @@ def remove_from(alist, to_remove):
         "tag": "main-ci",
         "folder": "build_tools/azure",
         "platform": "win-64",
-        "channel": "conda-forge",
+        "channels": ["conda-forge"],
         "conda_dependencies": remove_from(common_dependencies, ["pandas", "pyamg"])
         + [
             "wheel",
@@ -268,7 +288,7 @@ def remove_from(alist, to_remove):
         "tag": "main-ci",
         "folder": "build_tools/circle",
         "platform": "linux-64",
-        "channel": "conda-forge",
+        "channels": ["conda-forge"],
         "conda_dependencies": common_dependencies_without_coverage
         + [
             "scikit-image",
@@ -320,7 +340,7 @@ def remove_from(alist, to_remove):
         "tag": "main-ci",
         "folder": "build_tools/circle",
         "platform": "linux-64",
-        "channel": "conda-forge",
+        "channels": ["conda-forge"],
         "conda_dependencies": common_dependencies_without_coverage
         + [
             "scikit-image",
@@ -355,7 +375,7 @@ def remove_from(alist, to_remove):
         "tag": "arm",
         "folder": "build_tools/cirrus",
         "platform": "linux-aarch64",
-        "channel": "conda-forge",
+        "channels": ["conda-forge"],
         "conda_dependencies": remove_from(
             common_dependencies_without_coverage, ["pandas", "pyamg"]
         )
@@ -472,7 +492,9 @@ def get_conda_environment_content(build_metadata):
 # following script to centralize the configuration for CI builds:
 # build_tools/update_environments_and_lock_files.py
 channels:
-  - {{ build_metadata['channel'] }}
+  {% for channel in build_metadata['channels'] %}
+  - {{ channel }}
+  {% endfor %}
 dependencies:
   {% for conda_dep in build_metadata['conda_dependencies'] %}
   - {{ conda_dep | get_package_with_constraint(build_metadata) }}
@@ -720,6 +742,7 @@ def main(select_build, skip_build, select_tag, verbose, very_verbose):
     filtered_conda_build_metadata_list = [
         each for each in filtered_build_metadata_list if each["type"] == "conda"
     ]
+
     if filtered_conda_build_metadata_list:
         logger.info("# Writing conda environments")
         write_all_conda_environments(filtered_conda_build_metadata_list)

Weekly CI run with NVidia GPU hardware
Now that #22554 was merged in `main`, it would be great to find a a way  to run a weekly scheduled job to run the scikit-learn `main` test on a CI worker with an NVidia GPU and CuPy.

In case of failure, it could create a report as [dedicated issues](https://github.com/scikit-learn/scikit-learn/issues?q=is%3Aissue+%22%E2%9A%A0%EF%B8%8F+ci+failed+on%22+) as we do for other scheduled jobs:

- https://github.com/scikit-learn/scikit-learn/blob/main/maint_tools/update_tracking_issue.py

Maybe @betatim has a plan and connections to do that? ;)

Other opensource projects like dask and numba have accounts on https://gpuci.gpuopenanalytics.com/ for instance. Not sure what is the procedure to get scikit-learn accepted there (even with a limited quota of a few GPU hours per month).
Ive started looking into this
As discussed during the triaging meeting, we could use https://docs.cirun.io/ which is free for open source projects.

We would then need to register a credit card for our numfocus account on an AWS or Google Cloud.

If we do a few weekly runs, that should be cheap enough.

If we want to make it possible to trigger a GPU run from a git commit message in a PR we need to make sure that cirun would not spawn a GPU instance just to skip the build if the flag is not present in the git commit message, but it might be doable.
Progress update as of a few weeks ago: I got `cirun.io` working on my fork, but I ran into an issue with it stalling with PyTorch. I suspect it was because I was using GPUs that were too old, but I ran out of time to debug.

To reduce cost:

- I had GitHub Actions build the wheel and ship it over to `cirun.io`.
- I had a prebuilt image with the cuda dependencies installed.
Great news! Thanks very much for the progress report @thomasjpfan.
Is there a way to help out/join in? I think having a CI sooner rather than later would be really useful.

I think cirun is our only option
@thomasjpfan do you want someone else to try to takeover from your work in https://github.com/thomasjpfan/scikit-learn/tree/cirun_gpu if you don't have time to finalize this work yourself?
The latest work is at https://github.com/thomasjpfan/scikit-learn/tree/cirun_gcp (GCP)

I'll take another took at it this weekend. If I do not have a PR by Monday, then let's schedule a call so I can do a handoff.
After a few more iterations, it works now! https://github.com/thomasjpfan/scikit-learn/actions/runs/7820229387/job/21334434689

Next step is to to wrap it up into our workflow for triggering cirun.
It also works with T4s: https://github.com/thomasjpfan/scikit-learn/actions/runs/7820372944/job/21334925253 it looks to spin up faster, because there are more T4s available.
My understanding is that using custom Github runners exposes you to a lot of security issues, I am curious to know if there is any thoughts on how to mitigate this in scikit-learn, e.g. by only allowing a scheduled weekly run or some other way that make sure that running the GPU workflow is fully controlled by maintainers.

For exemple, according to [Hardening for self-hosted runners](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#hardening-for-self-hosted-runners) "self-hosted runners should almost never be used for public repositories".

Also using a custom github runner was a key aspect in the [PyTorch suppply-chain article](https://simonwillison.net/2024/Jan/14/supply-chain-attack-on-pytorch/)

I had a quick look at https://cirun.io and I could not find any mention about security there ...

I'm +1 on at least having a weekly run.

The hard part comes from PRs. I proposed using a "run-gpu-ci" GitHub label to trigger the CI. Here are the details:

1. When a maintainer adds a "run-gpu-ci" label, it will trigger a wheel build on the PR itself.
2. The wheel is send over a "run on gpu" workflow using `workflow_dispatch`.
	- This workflow type runs in the context of the main branch, so the only adjustable input is the wheel itself.
	- With `workflow_dispatch` only the "cirun.io" configuration on the `main` branch is used. PRs can not configure the runner itself.
4. The "run gpu" removes the "run-gpu-ci" label and sends the wheel to `cirun` for testing. (`pytest --pyargs sklearn`)

With the above approach, it requires a maintainer to add a "run-gpu-ci" label each time they want to trigger a gpu ci run. In the end, it is using labels to implement "Require approval for all outside collaborators’" just for the GPU runner.

There are two security concerns:

1. We make sure that only the scikit-learn team can add the "run-gpu-ci" label.
2. Before adding the label, the reviewer needs to make sure the code does not do anything malicious. Specifically, one can still run arbitrary code in a test.

I say 2 is the bigger concern, because we can miss something. @aktech What are the security concerns with running arbitrary code using `cirun.io`?

I agree that we will need a mechanism that allows core maintainers to approve a run of this custom runner. Labelling sounds good. I've also seen people use comments. Not sure what the pros and cons are.

As I understand it in the PyTorch case the attackers managed to run arbitrary code that happened to be "set this machine up to be a runner for our own repo". With a manual approval process (different from PyTorch) you'd have to spot that the PR contains such a "install backdoor" bit of code. Which can be easy but also hard. Once the backdoor is installed, all bets are off/there is nothing we can do anymore. In the PyTorch case they used their backdoor to wait for other workflows with more privileges and triggered by other people to run. Then grabbed the tokens/secrets those workflows had and used them to "do crime".

I think some things we could consider:
* the VM a workflow runs on should be deleted at the end of each run. That way an installed backdoor only survives for as long as that particular run.
* a short-ish timeout for workflow runs. That way `GITHUB_TOKEN`s you steal from a running workflow become useless after a short amount of time. The timeout has to be enforced "from the outside" as well as not be editable from inside the runner or our repo (so must be configured somewhere inside cirun's UI)
* as normal user or `root` on the VM that a workflow is running on I should not be able to access the cloud account that "owns" the VM. There are things like "metadata service" and "machine tokens" and so on that give you some information/access just based on the fact that you are a VM owned by the account. Otherwise people could try to replace the image of the VM that the runner uses, with one that includes a backdoor.
* disallow almost all egress from the VM. I think there is little need for our workflows to access the internet
* preventing people from running bitcoin miners is a bit trickier. There are things like the techniques used by https://github.com/cryptnono/cryptnono and such that are in use on mybinder.org - I'd hope we can piggyback on something from the cloud provider?
* minimal privileges everywhere for everything (standard measure)
* :+1: Thomas' plan to not use the workflow config of a PR when running the workflows of a PR, but sticking to the one in `main`
* as a general aim: make it so that you can't reconfigure the thing you are in, from inside it. What I mean is "you shouldn't be able to reconfigure the VM (image) from inside the VM" or "you should not be able to configure the workflow config of your PR from inside the PR"

---

Something I don't quite grasp yet is why custom runners are much more dangerous/susceptible than the GH provided runners. If we can understand this difference we will probably get some good ideas for how to harden our custom runner.
Thanks @thomasjpfan for the ping. I am more than happy to address any concerns and help implement CI for sckit-learn.

> @aktech What are the security concerns with running arbitrary code using cirun.io?

There aren't any security concerns that you wouldn't have on GitHub hosted runners. The runners created via cirun are ephemeral and are destroyed right after job completion. The most information one can know is the GCP project name, that's the basic metadata on the VM. The github token for runner provision is destroyed right after it's connected to GitHub and is ephemeral as well.

If you like you can also restrict runner creation by users/teams/permissions in the GitHub orgs via: https://docs.cirun.io/reference/access-control - conda-forge [uses this](https://github.com/conda-forge/.cirun) to restrict usage and access control CI.

> Something I don't quite grasp yet is why custom runners are much more dangerous/susceptible than the GH provided runners. If we can understand this difference we will probably get some good ideas for how to harden our custom runner.

@betatim checkout the second link [2]

As per GitHub docs

> This is not an issue with GitHub-hosted runners because each GitHub-hosted runner is always a clean isolated virtual machine, and it is destroyed at the end of the job execution.

Which is true for isolated VMs created by cirun as well.

Some ref:
- [1] https://docs.cirun.io/security
- [2] https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners#self-hosted-runner-security

@aktech thanks for adding a security section in the cirun.io doc! Maybe you want to add a link to the github doc you mentioned to show that you are aware of this and/or for people who are curious.
@lesteve [done](https://docs.cirun.io/security), thanks for pointing that out.
The labeling idea sounds about right (although I am clearly not an expert). Another thing I was wondering about: is there a way to make sure the secrets like the one to upload on PyPI or anaconda.org scientific-python-nightly-wheels are not available on the custom runner?

Edit: maybe https://github.blog/2021-04-13-implementing-least-privilege-for-secrets-in-github-actions/
For PyPI, we do not use secrets anymore and use [Trusted Publisher](https://docs.pypi.org/trusted-publishers/)

As for uploads to `scientific-python-nightly-wheels` or the staging index, we should create an GitHub Actions environment for them.
I opened https://github.com/scikit-learn/scikit-learn/pull/28441 to add an environment for the `upload_anaconda` secrets.
> After a few more iterations, it works now! https://github.com/thomasjpfan/scikit-learn/actions/runs/7820229387/job/21334434689

> Next step is to to wrap it up into our workflow for triggering cirun.

Now that the security concerns seem to have been cleared, I would like to understand in more details what is required to move forward.

From reading @thomasjpfan config at https://github.com/scikit-learn/scikit-learn/compare/main...thomasjpfan:scikit-learn:cirun_gcp, I have the following questions:

- where does the `third-campus-393023:scikit-learn-gpu-v4` machine image come from?

- do we need the `.devcontainer/devcontainer.json` as part of the CI itself, or was it just for local dev?

- in the previous plan written in https://github.com/scikit-learn/scikit-learn/issues/24491#issuecomment-1934123952, you mentioned using `workflow_dispatch` but I do not see that in the linked diff. Is the use of `workflow_dispatch` still relevant? or is the `pull_request` dispatch with `types: [labeled]` enough? From a security point of view, it seems that the `workflow_dispatch` is still required to avoid allowing external contributors to cheat by tweaking the github workflow files in their own PR to trigger the GPU spin-up without explicit maintainers control.

- assuming the latter and that we configure the `if: ${{ github.event.label.name == 'gpu' }}` condition on a github actions job configured with `runs-on: "cirun-gpu-runner--${{ github.run_id }}"`, and the surrounding workflow is configured with:

  ```python
  on:
    pull_request:
      types: [labeled]
  ```

  will cirun spin-up a costly GPU VM just to run the build condition (e.g. check the presence of the label on the PR) or will the GPU VM start (and be build) only if the trigger condition evaluates to true?

I can answer a couple of questions:

> where does the third-campus-393023:scikit-learn-gpu-v4 machine image come from?

This is the custom image @thomasjpfan created in his GCP account with nvidia drivers installed (and maybe something else too).

>will cirun spin-up a costly GPU VM just to run the build condition (e.g. check the presence of the label on the PR) or will the GPU VM start (and be build) only if the trigger condition evaluates to true?

You can do this by checking if GPU tests should run or not in GitHub hosted runner (non-gpu) and then trigger the job with GPU if required, example:

<img width="654" alt="Screenshot 2024-03-15 at 1 42 52 pm" src="https://github.com/scikit-learn/scikit-learn/assets/5647941/a7bc0853-e5c8-4240-8ca0-bcabeb196e88">

Ref: https://github.com/scverse/anndata/actions/runs/8277985942
> where does the third-campus-393023:scikit-learn-gpu-v4 machine image come from?

This is a custom image on my GCP account with conda + nvidia drivers installed. This makes start up times much faster, at the cost of storing an image in GCP.

> do we need the .devcontainer/devcontainer.json as part of the CI itself, or was it just for local dev?

I mixed up testing Codespaces into my GPU+CI testing branch.

> Is the use of workflow_dispatch still relevant?

This was my mistake, the GitHub event should either be `workflow_run` or `pull_request_target`. I think these events are still required because of the security concerns. I feel like `pull_request_target` could be good enough, but that needs some testing. I did not configured this in my experimentation.

CIRun does have [access controls](https://docs.cirun.io/reference/access-control), but I do not think it works for our workflow of PRs from external users. <s>Also, we'll need</s>

> Also, we'll need

It seems that you did not finish your sentence.
> It seems that you did not finish your sentence.

I reordered the paragraphs and did not remove the last part in the editing. 😅
>> where does the third-campus-393023:scikit-learn-gpu-v4 machine image come from?

> This is a custom image on my GCP account with conda + nvidia drivers installed. This makes start up times much faster, at the cost of storing an image in GCP.

Could you please push the `Dockerfile` to your branch to make it more convenient to update this container from time to time when dependencies are updated?
I did not use a `Dockerfile` to create the custom image on GCP. I had a running instance, installed the Nvidia drivers + conda, and then created a clone.

I do not know if there is a way to create a custom image with Docker on GCP.

REF: https://docs.cirun.io/custom-images/cloud-custom-images#gcp-custom-images
I don't think it's possible to generate VM images from Dockerfile, the last I checked. To programmatically generate VM images you can use something like packer: https://developer.hashicorp.com/packer/integrations/hashicorp/googlecompute/latest/components/builder/googlecompute

Example: https://github.com/actions/runner-images/blob/98d2bcc93e055d8892f9446e72a4da66b334bfb1/images/ubuntu/templates/ubuntu-22.04.pkr.hcl
Why would a custom VM image be a better solution that using a custom docker image in a standard GCP image?
> Why would a custom VM image be a better solution that using a custom docker image in a standard GCP image?

It's not about which one is better, either can be done with different levels of complexity. You can build a custom docker image, but in that case you'll have to do these things:

- Still have to create a custom image with [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html#installing-the-nvidia-container-toolkit) installed, this will help in building and running GPU-accelerated containers.
- Run your tests inside docker, which will add more complexity in the scikit-learn CI.

For the record, GitHub has recently announced a public beta for GitHub-hosted runners with GPU capabilities:

- https://github.blog/2024-04-02-bringing-enterprise-level-security-and-even-more-power-to-github-hosted-runners/#gpu-hosted-runners-available-in-public-beta-%f0%9f%8e%89

However, the scikit-learn organization does not seem to have the ability to create such runners with its GitHub Team plan at the moment.

EDIT: I wrote the above too quickly, apparently @betatim figured it out: we need to use a "partner image" from NVIDIA when creating the runner to see the GPU option.


diff --git a/RELEASE.md b/RELEASE.md
new file mode 100644
index 0000000000..0ad55f9b02
--- /dev/null
+++ b/RELEASE.md
@@ -0,0 +1,5 @@
+Release type: minor
+
+In this release, all types of the legacy graphql-ws protocol were refactored.
+The types are now much stricter and precisely model the difference between null and undefined fields.
+As a result, our protocol implementation and related tests are now more robust and easier to maintain.
diff --git a/strawberry/subscriptions/protocols/graphql_ws/__init__.py b/strawberry/subscriptions/protocols/graphql_ws/__init__.py
index 2b3696e822..e69de29bb2 100644
--- a/strawberry/subscriptions/protocols/graphql_ws/__init__.py
+++ b/strawberry/subscriptions/protocols/graphql_ws/__init__.py
@@ -1,24 +0,0 @@
-GQL_CONNECTION_INIT = "connection_init"
-GQL_CONNECTION_ACK = "connection_ack"
-GQL_CONNECTION_ERROR = "connection_error"
-GQL_CONNECTION_TERMINATE = "connection_terminate"
-GQL_CONNECTION_KEEP_ALIVE = "ka"
-GQL_START = "start"
-GQL_DATA = "data"
-GQL_ERROR = "error"
-GQL_COMPLETE = "complete"
-GQL_STOP = "stop"
-
-
-__all__ = [
-    "GQL_CONNECTION_INIT",
-    "GQL_CONNECTION_ACK",
-    "GQL_CONNECTION_ERROR",
-    "GQL_CONNECTION_TERMINATE",
-    "GQL_CONNECTION_KEEP_ALIVE",
-    "GQL_START",
-    "GQL_DATA",
-    "GQL_ERROR",
-    "GQL_COMPLETE",
-    "GQL_STOP",
-]
diff --git a/strawberry/subscriptions/protocols/graphql_ws/handlers.py b/strawberry/subscriptions/protocols/graphql_ws/handlers.py
index 3237bade18..9b2eddbf85 100644
--- a/strawberry/subscriptions/protocols/graphql_ws/handlers.py
+++ b/strawberry/subscriptions/protocols/graphql_ws/handlers.py
@@ -11,24 +11,18 @@
 )

 from strawberry.http.exceptions import NonTextMessageReceived, WebSocketDisconnected
-from strawberry.subscriptions.protocols.graphql_ws import (
-    GQL_COMPLETE,
-    GQL_CONNECTION_ACK,
-    GQL_CONNECTION_ERROR,
-    GQL_CONNECTION_INIT,
-    GQL_CONNECTION_KEEP_ALIVE,
-    GQL_CONNECTION_TERMINATE,
-    GQL_DATA,
-    GQL_ERROR,
-    GQL_START,
-    GQL_STOP,
-)
 from strawberry.subscriptions.protocols.graphql_ws.types import (
-    ConnectionInitPayload,
-    DataPayload,
+    CompleteMessage,
+    ConnectionAckMessage,
+    ConnectionErrorMessage,
+    ConnectionInitMessage,
+    ConnectionKeepAliveMessage,
+    ConnectionTerminateMessage,
+    DataMessage,
+    ErrorMessage,
     OperationMessage,
-    OperationMessagePayload,
-    StartPayload,
+    StartMessage,
+    StopMessage,
 )
 from strawberry.types.execution import ExecutionResult, PreExecutionError
 from strawberry.utils.debug import pretty_print_graphql_operation
@@ -59,7 +53,7 @@ def __init__(
         self.keep_alive_task: Optional[asyncio.Task] = None
         self.subscriptions: Dict[str, AsyncGenerator] = {}
         self.tasks: Dict[str, asyncio.Task] = {}
-        self.connection_params: Optional[ConnectionInitPayload] = None
+        self.connection_params: Optional[Dict[str, object]] = None

     async def handle(self) -> None:
         try:
@@ -87,41 +81,40 @@ async def handle_message(
         self,
         message: OperationMessage,
     ) -> None:
-        message_type = message["type"]
-
-        if message_type == GQL_CONNECTION_INIT:
+        if message["type"] == "connection_init":
             await self.handle_connection_init(message)
-        elif message_type == GQL_CONNECTION_TERMINATE:
+        elif message["type"] == "connection_terminate":
             await self.handle_connection_terminate(message)
-        elif message_type == GQL_START:
+        elif message["type"] == "start":
             await self.handle_start(message)
-        elif message_type == GQL_STOP:
+        elif message["type"] == "stop":
             await self.handle_stop(message)

-    async def handle_connection_init(self, message: OperationMessage) -> None:
+    async def handle_connection_init(self, message: ConnectionInitMessage) -> None:
         payload = message.get("payload")
         if payload is not None and not isinstance(payload, dict):
-            error_message: OperationMessage = {"type": GQL_CONNECTION_ERROR}
+            error_message: ConnectionErrorMessage = {"type": "connection_error"}
             await self.websocket.send_json(error_message)
             await self.websocket.close(code=1000, reason="")
             return

-        payload = cast(Optional["ConnectionInitPayload"], payload)
         self.connection_params = payload

-        acknowledge_message: OperationMessage = {"type": GQL_CONNECTION_ACK}
-        await self.websocket.send_json(acknowledge_message)
+        connection_ack_message: ConnectionAckMessage = {"type": "connection_ack"}
+        await self.websocket.send_json(connection_ack_message)

         if self.keep_alive:
             keep_alive_handler = self.handle_keep_alive()
             self.keep_alive_task = asyncio.create_task(keep_alive_handler)

-    async def handle_connection_terminate(self, message: OperationMessage) -> None:
+    async def handle_connection_terminate(
+        self, message: ConnectionTerminateMessage
+    ) -> None:
         await self.websocket.close(code=1000, reason="")

-    async def handle_start(self, message: OperationMessage) -> None:
+    async def handle_start(self, message: StartMessage) -> None:
         operation_id = message["id"]
-        payload = cast("StartPayload", message["payload"])
+        payload = message["payload"]
         query = payload["query"]
         operation_name = payload.get("operationName")
         variables = payload.get("variables")
@@ -139,14 +132,14 @@ async def handle_start(self, message: OperationMessage) -> None:
         )
         self.tasks[operation_id] = asyncio.create_task(result_handler)

-    async def handle_stop(self, message: OperationMessage) -> None:
+    async def handle_stop(self, message: StopMessage) -> None:
         operation_id = message["id"]
         await self.cleanup_operation(operation_id)

     async def handle_keep_alive(self) -> None:
         assert self.keep_alive_interval
         while True:
-            data: OperationMessage = {"type": GQL_CONNECTION_KEEP_ALIVE}
+            data: ConnectionKeepAliveMessage = {"type": "ka"}
             await self.websocket.send_json(data)
             await asyncio.sleep(self.keep_alive_interval)

@@ -168,14 +161,24 @@ async def handle_async_results(
             if isinstance(agen_or_err, PreExecutionError):
                 assert agen_or_err.errors
                 error_payload = agen_or_err.errors[0].formatted
-                await self.send_message(GQL_ERROR, operation_id, error_payload)
+                error_message: ErrorMessage = {
+                    "type": "error",
+                    "id": operation_id,
+                    "payload": error_payload,
+                }
+                await self.websocket.send_json(error_message)
             else:
                 self.subscriptions[operation_id] = agen_or_err
                 async for result in agen_or_err:
                     await self.send_data(result, operation_id)
-                await self.send_message(GQL_COMPLETE, operation_id, None)
+                complete_message: CompleteMessage = {
+                    "type": "complete",
+                    "id": operation_id,
+                }
+                await self.websocket.send_json(complete_message)
         except asyncio.CancelledError:
-            await self.send_message(GQL_COMPLETE, operation_id, None)
+            complete_message: CompleteMessage = {"type": "complete", "id": operation_id}
+            await self.websocket.send_json(complete_message)

     async def cleanup_operation(self, operation_id: str) -> None:
         if operation_id in self.subscriptions:
@@ -188,26 +191,24 @@ async def cleanup_operation(self, operation_id: str) -> None:
             await self.tasks[operation_id]
         del self.tasks[operation_id]

-    async def send_message(
-        self,
-        type_: str,
-        operation_id: str,
-        payload: Optional[OperationMessagePayload] = None,
-    ) -> None:
-        data: OperationMessage = {"type": type_, "id": operation_id}
-        if payload is not None:
-            data["payload"] = payload
-        await self.websocket.send_json(data)
-
     async def send_data(
         self, execution_result: ExecutionResult, operation_id: str
     ) -> None:
-        payload: DataPayload = {"data": execution_result.data}
+        data_message: DataMessage = {
+            "type": "data",
+            "id": operation_id,
+            "payload": {"data": execution_result.data},
+        }
+
         if execution_result.errors:
-            payload["errors"] = [err.formatted for err in execution_result.errors]
+            data_message["payload"]["errors"] = [
+                err.formatted for err in execution_result.errors
+            ]
+
         if execution_result.extensions:
-            payload["extensions"] = execution_result.extensions
-        await self.send_message(GQL_DATA, operation_id, payload)
+            data_message["payload"]["extensions"] = execution_result.extensions
+
+        await self.websocket.send_json(data_message)

 __all__ = ["BaseGraphQLWSHandler"]
diff --git a/strawberry/subscriptions/protocols/graphql_ws/types.py b/strawberry/subscriptions/protocols/graphql_ws/types.py
index 5ada0b100a..ee0dd06921 100644
--- a/strawberry/subscriptions/protocols/graphql_ws/types.py
+++ b/strawberry/subscriptions/protocols/graphql_ws/types.py
@@ -1,52 +1,96 @@
-from typing import Any, Dict, List, Optional, Union
-from typing_extensions import TypedDict
+from typing import Dict, List, TypedDict, Union
+from typing_extensions import Literal, NotRequired

 from graphql import GraphQLFormattedError

-ConnectionInitPayload = Dict[str, Any]

+class ConnectionInitMessage(TypedDict):
+    type: Literal["connection_init"]
+    payload: NotRequired[Dict[str, object]]

-ConnectionErrorPayload = Dict[str, Any]

-
-class StartPayload(TypedDict, total=False):
+class StartMessagePayload(TypedDict):
     query: str
-    variables: Optional[Dict[str, Any]]
-    operationName: Optional[str]
+    variables: NotRequired[Dict[str, object]]
+    operationName: NotRequired[str]
+
+
+class StartMessage(TypedDict):
+    type: Literal["start"]
+    id: str
+    payload: StartMessagePayload

-class DataPayload(TypedDict, total=False):
-    data: Any
+class StopMessage(TypedDict):
+    type: Literal["stop"]
+    id: str

-    # Optional list of formatted graphql.GraphQLError objects
-    errors: Optional[List[GraphQLFormattedError]]
-    extensions: Optional[Dict[str, Any]]

+class ConnectionTerminateMessage(TypedDict):
+    type: Literal["connection_terminate"]

-ErrorPayload = GraphQLFormattedError

+class ConnectionErrorMessage(TypedDict):
+    type: Literal["connection_error"]
+    payload: NotRequired[Dict[str, object]]

-OperationMessagePayload = Union[
-    ConnectionInitPayload,
-    ConnectionErrorPayload,
-    StartPayload,
-    DataPayload,
-    ErrorPayload,
-]

+class ConnectionAckMessage(TypedDict):
+    type: Literal["connection_ack"]

-class OperationMessage(TypedDict, total=False):
-    type: str
+
+class DataMessagePayload(TypedDict):
+    data: object
+    errors: NotRequired[List[GraphQLFormattedError]]
+
+    # Non-standard field:
+    extensions: NotRequired[Dict[str, object]]
+
+
+class DataMessage(TypedDict):
+    type: Literal["data"]
     id: str
-    payload: OperationMessagePayload
+    payload: DataMessagePayload
+
+
+class ErrorMessage(TypedDict):
+    type: Literal["error"]
+    id: str
+    payload: GraphQLFormattedError
+
+
+class CompleteMessage(TypedDict):
+    type: Literal["complete"]
+    id: str
+
+
+class ConnectionKeepAliveMessage(TypedDict):
+    type: Literal["ka"]
+
+
+OperationMessage = Union[
+    ConnectionInitMessage,
+    StartMessage,
+    StopMessage,
+    ConnectionTerminateMessage,
+    ConnectionErrorMessage,
+    ConnectionAckMessage,
+    DataMessage,
+    ErrorMessage,
+    CompleteMessage,
+]

 __all__ = [
-    "ConnectionInitPayload",
-    "ConnectionErrorPayload",
-    "StartPayload",
-    "DataPayload",
-    "ErrorPayload",
-    "OperationMessagePayload",
+    "ConnectionInitMessage",
+    "StartMessage",
+    "StopMessage",
+    "ConnectionTerminateMessage",
+    "ConnectionErrorMessage",
+    "ConnectionAckMessage",
+    "DataMessage",
+    "ErrorMessage",
+    "CompleteMessage",
+    "ConnectionKeepAliveMessage",
     "OperationMessage",
 ]

diff --git a/strawberry/channels/testing.py b/strawberry/channels/testing.py
index 890c7147d2..ec129263b5 100644
--- a/strawberry/channels/testing.py
+++ b/strawberry/channels/testing.py
@@ -1,6 +1,5 @@
 from __future__ import annotations

-import dataclasses
 import uuid
 from typing import (
     TYPE_CHECKING,
@@ -26,10 +25,14 @@
     SubscribeMessage,
     SubscribeMessagePayload,
 )
-from strawberry.subscriptions.protocols.graphql_ws import (
-    GQL_CONNECTION_ACK,
-    GQL_CONNECTION_INIT,
-    GQL_START,
+from strawberry.subscriptions.protocols.graphql_ws.types import (
+    ConnectionAckMessage as GraphQLWSConnectionAckMessage,
+)
+from strawberry.subscriptions.protocols.graphql_ws.types import (
+    ConnectionInitMessage as GraphQLWSConnectionInitMessage,
+)
+from strawberry.subscriptions.protocols.graphql_ws.types import (
+    StartMessage as GraphQLWSStartMessage,
 )
 from strawberry.types import ExecutionResult

@@ -112,9 +115,11 @@ async def gql_init(self) -> None:
             assert response == ConnectionAckMessage().as_dict()
         else:
             assert res == (True, GRAPHQL_WS_PROTOCOL)
-            await self.send_json_to({"type": GQL_CONNECTION_INIT})
-            response = await self.receive_json_from()
-            assert response["type"] == GQL_CONNECTION_ACK
+            await self.send_json_to(
+                GraphQLWSConnectionInitMessage({"type": "connection_init"})
+            )
+            response: GraphQLWSConnectionAckMessage = await self.receive_json_from()
+            assert response["type"] == "connection_ack"

     # Actual `ExecutionResult`` objects are not available client-side, since they
     # get transformed into `FormattedExecutionResult` on the wire, but we attempt
@@ -123,22 +128,28 @@ async def subscribe(
         self, query: str, variables: Optional[Dict] = None
     ) -> Union[ExecutionResult, AsyncIterator[ExecutionResult]]:
         id_ = uuid.uuid4().hex
-        sub_payload = SubscribeMessagePayload(query=query, variables=variables)
+
         if self.protocol == GRAPHQL_TRANSPORT_WS_PROTOCOL:
             await self.send_json_to(
                 SubscribeMessage(
                     id=id_,
-                    payload=sub_payload,
+                    payload=SubscribeMessagePayload(query=query, variables=variables),
                 ).as_dict()
             )
         else:
-            await self.send_json_to(
-                {
-                    "type": GQL_START,
-                    "id": id_,
-                    "payload": dataclasses.asdict(sub_payload),
-                }
-            )
+            start_message: GraphQLWSStartMessage = {
+                "type": "start",
+                "id": id_,
+                "payload": {
+                    "query": query,
+                },
+            }
+
+            if variables is not None:
+                start_message["payload"]["variables"] = variables
+
+            await self.send_json_to(start_message)
+
         while True:
             response = await self.receive_json_from(timeout=5)
             message_type = response["type"]
diff --git a/tests/fastapi/test_context.py b/tests/fastapi/test_context.py
index a5b6ea9ec8..22e8a56765 100644
--- a/tests/fastapi/test_context.py
+++ b/tests/fastapi/test_context.py
@@ -14,14 +14,26 @@
     SubscribeMessage,
     SubscribeMessagePayload,
 )
-from strawberry.subscriptions.protocols.graphql_ws import (
-    GQL_COMPLETE,
-    GQL_CONNECTION_ACK,
-    GQL_CONNECTION_INIT,
-    GQL_CONNECTION_TERMINATE,
-    GQL_DATA,
-    GQL_START,
-    GQL_STOP,
+from strawberry.subscriptions.protocols.graphql_ws.types import (
+    CompleteMessage as GraphQLWSCompleteMessage,
+)
+from strawberry.subscriptions.protocols.graphql_ws.types import (
+    ConnectionAckMessage as GraphQLWSConnectionAckMessage,
+)
+from strawberry.subscriptions.protocols.graphql_ws.types import (
+    ConnectionInitMessage as GraphQLWSConnectionInitMessage,
+)
+from strawberry.subscriptions.protocols.graphql_ws.types import (
+    ConnectionTerminateMessage as GraphQLWSConnectionTerminateMessage,
+)
+from strawberry.subscriptions.protocols.graphql_ws.types import (
+    DataMessage as GraphQLWSDataMessage,
+)
+from strawberry.subscriptions.protocols.graphql_ws.types import (
+    StartMessage as GraphQLWSStartMessage,
+)
+from strawberry.subscriptions.protocols.graphql_ws.types import (
+    StopMessage as GraphQLWSStopMessage,
 )

@@ -298,36 +310,42 @@ def get_context(context: Context = Depends()) -> Context:

     with test_client.websocket_connect("/graphql", [GRAPHQL_WS_PROTOCOL]) as ws:
         ws.send_json(
-            {
-                "type": GQL_CONNECTION_INIT,
-                "id": "demo",
-                "payload": {"strawberry": "rocks"},
-            }
+            GraphQLWSConnectionInitMessage(
+                {
+                    "type": "connection_init",
+                    "payload": {"strawberry": "rocks"},
+                }
+            )
         )
         ws.send_json(
-            {
-                "type": GQL_START,
-                "id": "demo",
-                "payload": {
-                    "query": "subscription { connectionParams }",
-                },
-            }
+            GraphQLWSStartMessage(
+                {
+                    "type": "start",
+                    "id": "demo",
+                    "payload": {
+                        "query": "subscription { connectionParams }",
+                    },
+                }
+            )
         )

-        response = ws.receive_json()
-        assert response["type"] == GQL_CONNECTION_ACK
+        connection_ack_message: GraphQLWSConnectionAckMessage = ws.receive_json()
+        assert connection_ack_message["type"] == "connection_ack"

-        response = ws.receive_json()
-        assert response["type"] == GQL_DATA
-        assert response["id"] == "demo"
-        assert response["payload"]["data"] == {"connectionParams": "rocks"}
+        data_message: GraphQLWSDataMessage = ws.receive_json()
+        assert data_message["type"] == "data"
+        assert data_message["id"] == "demo"
+        assert data_message["payload"]["data"] == {"connectionParams": "rocks"}

-        ws.send_json({"type": GQL_STOP, "id": "demo"})
-        response = ws.receive_json()
-        assert response["type"] == GQL_COMPLETE
-        assert response["id"] == "demo"
+        ws.send_json(GraphQLWSStopMessage({"type": "stop", "id": "demo"}))

-        ws.send_json({"type": GQL_CONNECTION_TERMINATE})
+        complete_message: GraphQLWSCompleteMessage = ws.receive_json()
+        assert complete_message["type"] == "complete"
+        assert complete_message["id"] == "demo"
+
+        ws.send_json(
+            GraphQLWSConnectionTerminateMessage({"type": "connection_terminate"})
+        )

         # make sure the websocket is disconnected now
         with pytest.raises(WebSocketDisconnect):
diff --git a/tests/http/clients/base.py b/tests/http/clients/base.py
index c85b2efe00..95923b9c9b 100644
--- a/tests/http/clients/base.py
+++ b/tests/http/clients/base.py
@@ -268,7 +268,7 @@ def name(self) -> str:
     async def send_text(self, payload: str) -> None: ...

     @abc.abstractmethod
-    async def send_json(self, payload: Dict[str, Any]) -> None: ...
+    async def send_json(self, payload: Mapping[str, object]) -> None: ...

     @abc.abstractmethod
     async def send_bytes(self, payload: bytes) -> None: ...
diff --git a/tests/websockets/test_graphql_ws.py b/tests/websockets/test_graphql_ws.py
index 73c302e161..3c3765c5ff 100644
--- a/tests/websockets/test_graphql_ws.py
+++ b/tests/websockets/test_graphql_ws.py
@@ -2,24 +2,24 @@

 import asyncio
 import json
-from typing import TYPE_CHECKING, AsyncGenerator
+from typing import TYPE_CHECKING, AsyncGenerator, Union
 from unittest import mock

 import pytest
 import pytest_asyncio

 from strawberry.subscriptions import GRAPHQL_WS_PROTOCOL
-from strawberry.subscriptions.protocols.graphql_ws import (
-    GQL_COMPLETE,
-    GQL_CONNECTION_ACK,
-    GQL_CONNECTION_ERROR,
-    GQL_CONNECTION_INIT,
-    GQL_CONNECTION_KEEP_ALIVE,
-    GQL_CONNECTION_TERMINATE,
-    GQL_DATA,
-    GQL_ERROR,
-    GQL_START,
-    GQL_STOP,
+from strawberry.subscriptions.protocols.graphql_ws.types import (
+    CompleteMessage,
+    ConnectionAckMessage,
+    ConnectionErrorMessage,
+    ConnectionInitMessage,
+    ConnectionKeepAliveMessage,
+    ConnectionTerminateMessage,
+    DataMessage,
+    ErrorMessage,
+    StartMessage,
+    StopMessage,
 )
 from tests.views.schema import MyExtension, Schema

@@ -40,13 +40,14 @@ async def ws_raw(http_client: HttpClient) -> AsyncGenerator[WebSocketClient, Non
 @pytest_asyncio.fixture
 async def ws(ws_raw: WebSocketClient) -> AsyncGenerator[WebSocketClient, None]:
     ws = ws_raw
-    await ws.send_json({"type": GQL_CONNECTION_INIT})
-    response = await ws.receive_json()
-    assert response["type"] == GQL_CONNECTION_ACK
+
+    await ws.send_json(ConnectionInitMessage({"type": "connection_init"}))
+    response: ConnectionAckMessage = await ws.receive_json()
+    assert response["type"] == "connection_ack"

     yield ws

-    await ws.send_json({"type": GQL_CONNECTION_TERMINATE})
+    await ws.send_json(ConnectionTerminateMessage({"type": "connection_terminate"}))
     # make sure the WebSocket is disconnected now
     await ws.receive(timeout=2)  # receive close
     assert ws.closed
@@ -60,50 +61,56 @@ def aiohttp_app_client(http_client: HttpClient) -> HttpClient:

 async def test_simple_subscription(ws: WebSocketClient):
     await ws.send_json(
-        {
-            "type": GQL_START,
-            "id": "demo",
-            "payload": {
-                "query": 'subscription { echo(message: "Hi") }',
-            },
-        }
+        StartMessage(
+            {
+                "type": "start",
+                "id": "demo",
+                "payload": {
+                    "query": 'subscription { echo(message: "Hi") }',
+                },
+            }
+        )
     )

-    response = await ws.receive_json()
-    assert response["type"] == GQL_DATA
-    assert response["id"] == "demo"
-    assert response["payload"]["data"] == {"echo": "Hi"}
+    data_message: DataMessage = await ws.receive_json()
+    assert data_message["type"] == "data"
+    assert data_message["id"] == "demo"
+    assert data_message["payload"]["data"] == {"echo": "Hi"}
+
+    await ws.send_json(StopMessage({"type": "stop", "id": "demo"}))

-    await ws.send_json({"type": GQL_STOP, "id": "demo"})
-    response = await ws.receive_json()
-    assert response["type"] == GQL_COMPLETE
-    assert response["id"] == "demo"
+    complete_message: CompleteMessage = await ws.receive_json()
+    assert complete_message["type"] == "complete"
+    assert complete_message["id"] == "demo"

 async def test_operation_selection(ws: WebSocketClient):
     await ws.send_json(
-        {
-            "type": GQL_START,
-            "id": "demo",
-            "payload": {
-                "query": """
+        StartMessage(
+            {
+                "type": "start",
+                "id": "demo",
+                "payload": {
+                    "query": """
                     subscription Subscription1 { echo(message: "Hi1") }
                     subscription Subscription2 { echo(message: "Hi2") }
                 """,
-                "operationName": "Subscription2",
-            },
-        }
+                    "operationName": "Subscription2",
+                },
+            }
+        )
     )

-    response = await ws.receive_json()
-    assert response["type"] == GQL_DATA
-    assert response["id"] == "demo"
-    assert response["payload"]["data"] == {"echo": "Hi2"}
+    data_message: DataMessage = await ws.receive_json()
+    assert data_message["type"] == "data"
+    assert data_message["id"] == "demo"
+    assert data_message["payload"]["data"] == {"echo": "Hi2"}
+
+    await ws.send_json(StopMessage({"type": "stop", "id": "demo"}))

-    await ws.send_json({"type": GQL_STOP, "id": "demo"})
-    response = await ws.receive_json()
-    assert response["type"] == GQL_COMPLETE
-    assert response["id"] == "demo"
+    complete_message: CompleteMessage = await ws.receive_json()
+    assert complete_message["type"] == "complete"
+    assert complete_message["id"] == "demo"

 async def test_sends_keep_alive(aiohttp_app_client: HttpClient):
@@ -111,151 +118,174 @@ async def test_sends_keep_alive(aiohttp_app_client: HttpClient):
     async with aiohttp_app_client.ws_connect(
         "/graphql", protocols=[GRAPHQL_WS_PROTOCOL]
     ) as ws:
-        await ws.send_json({"type": GQL_CONNECTION_INIT})
+        await ws.send_json(ConnectionInitMessage({"type": "connection_init"}))
         await ws.send_json(
-            {
-                "type": GQL_START,
-                "id": "demo",
-                "payload": {
-                    "query": 'subscription { echo(message: "Hi", delay: 0.15) }',
-                },
-            }
+            StartMessage(
+                {
+                    "type": "start",
+                    "id": "demo",
+                    "payload": {
+                        "query": 'subscription { echo(message: "Hi", delay: 0.15) }',
+                    },
+                }
+            )
         )

-        response = await ws.receive_json()
-        assert response["type"] == GQL_CONNECTION_ACK
+        ack_message: ConnectionAckMessage = await ws.receive_json()
+        assert ack_message["type"] == "connection_ack"

         # we can't be sure how many keep-alives exactly we
         # get but they should be more than one.
         keepalive_count = 0
         while True:
-            response = await ws.receive_json()
-            if response["type"] == GQL_CONNECTION_KEEP_ALIVE:
+            ka_or_data_message: Union[
+                ConnectionKeepAliveMessage, DataMessage
+            ] = await ws.receive_json()
+            if ka_or_data_message["type"] == "ka":
                 keepalive_count += 1
             else:
                 break
         assert keepalive_count >= 1

-        assert response["type"] == GQL_DATA
-        assert response["id"] == "demo"
-        assert response["payload"]["data"] == {"echo": "Hi"}
+        assert ka_or_data_message["type"] == "data"
+        assert ka_or_data_message["id"] == "demo"
+        assert ka_or_data_message["payload"]["data"] == {"echo": "Hi"}

-        response = await ws.receive_json()
-        assert response["type"] == GQL_COMPLETE
-        assert response["id"] == "demo"
+        complete_message: CompleteMessage = await ws.receive_json()
+        assert complete_message["type"] == "complete"
+        assert complete_message["id"] == "demo"

-        await ws.send_json({"type": GQL_CONNECTION_TERMINATE})
+        await ws.send_json(ConnectionTerminateMessage({"type": "connection_terminate"}))

 async def test_subscription_cancellation(ws: WebSocketClient):
     await ws.send_json(
-        {
-            "type": GQL_START,
-            "id": "demo",
-            "payload": {"query": 'subscription { echo(message: "Hi", delay: 99) }'},
-        }
+        StartMessage(
+            {
+                "type": "start",
+                "id": "demo",
+                "payload": {"query": 'subscription { echo(message: "Hi", delay: 99) }'},
+            }
+        )
     )

     await ws.send_json(
-        {
-            "type": GQL_START,
-            "id": "debug1",
-            "payload": {
-                "query": "subscription { debug { numActiveResultHandlers } }",
-            },
-        }
+        StartMessage(
+            {
+                "type": "start",
+                "id": "debug1",
+                "payload": {
+                    "query": "subscription { debug { numActiveResultHandlers } }",
+                },
+            }
+        )
     )

-    response = await ws.receive_json()
-    assert response["type"] == GQL_DATA
-    assert response["id"] == "debug1"
-    assert response["payload"]["data"] == {"debug": {"numActiveResultHandlers": 2}}
+    data_message: DataMessage = await ws.receive_json()
+    assert data_message["type"] == "data"
+    assert data_message["id"] == "debug1"
+    assert data_message["payload"]["data"] == {"debug": {"numActiveResultHandlers": 2}}
+
+    complete_message1 = await ws.receive_json()
+    assert complete_message1["type"] == "complete"
+    assert complete_message1["id"] == "debug1"

-    response = await ws.receive_json()
-    assert response["type"] == GQL_COMPLETE
-    assert response["id"] == "debug1"
+    await ws.send_json(StopMessage({"type": "stop", "id": "demo"}))

-    await ws.send_json({"type": GQL_STOP, "id": "demo"})
-    response = await ws.receive_json()
-    assert response["type"] == GQL_COMPLETE
-    assert response["id"] == "demo"
+    complete_message2 = await ws.receive_json()
+    assert complete_message2["type"] == "complete"
+    assert complete_message2["id"] == "demo"

     await ws.send_json(
-        {
-            "type": GQL_START,
-            "id": "debug2",
-            "payload": {
-                "query": "subscription { debug { numActiveResultHandlers} }",
-            },
-        }
+        StartMessage(
+            {
+                "type": "start",
+                "id": "debug2",
+                "payload": {
+                    "query": "subscription { debug { numActiveResultHandlers} }",
+                },
+            }
+        )
     )

-    response = await ws.receive_json()
-    assert response["type"] == GQL_DATA
-    assert response["id"] == "debug2"
-    assert response["payload"]["data"] == {"debug": {"numActiveResultHandlers": 1}}
+    data_message2 = await ws.receive_json()
+    assert data_message2["type"] == "data"
+    assert data_message2["id"] == "debug2"
+    assert data_message2["payload"]["data"] == {"debug": {"numActiveResultHandlers": 1}}

-    response = await ws.receive_json()
-    assert response["type"] == GQL_COMPLETE
-    assert response["id"] == "debug2"
+    complete_message3: CompleteMessage = await ws.receive_json()
+    assert complete_message3["type"] == "complete"
+    assert complete_message3["id"] == "debug2"

 async def test_subscription_errors(ws: WebSocketClient):
     await ws.send_json(
-        {
-            "type": GQL_START,
-            "id": "demo",
-            "payload": {"query": 'subscription { error(message: "TEST ERR") }'},
-        }
+        StartMessage(
+            {
+                "type": "start",
+                "id": "demo",
+                "payload": {"query": 'subscription { error(message: "TEST ERR") }'},
+            }
+        )
     )

-    response = await ws.receive_json()
-    assert response["type"] == GQL_DATA
-    assert response["id"] == "demo"
-    assert response["payload"]["data"] is None
-    assert len(response["payload"]["errors"]) == 1
-    assert response["payload"]["errors"][0]["path"] == ["error"]
-    assert response["payload"]["errors"][0]["message"] == "TEST ERR"
+    data_message: DataMessage = await ws.receive_json()
+    assert data_message["type"] == "data"
+    assert data_message["id"] == "demo"
+    assert data_message["payload"]["data"] is None
+
+    data_payload_errors = data_message["payload"].get("errors")
+    assert data_payload_errors is not None
+    assert len(data_payload_errors) == 1
+    assert data_payload_errors[0].get("path") == ["error"]
+    assert data_payload_errors[0].get("message") == "TEST ERR"

-    response = await ws.receive_json()
-    assert response["type"] == GQL_COMPLETE
-    assert response["id"] == "demo"
+    complete_message: CompleteMessage = await ws.receive_json()
+    assert complete_message["type"] == "complete"
+    assert complete_message["id"] == "demo"

 async def test_subscription_exceptions(ws: WebSocketClient):
     await ws.send_json(
-        {
-            "type": GQL_START,
-            "id": "demo",
-            "payload": {"query": 'subscription { exception(message: "TEST EXC") }'},
-        }
+        StartMessage(
+            {
+                "type": "start",
+                "id": "demo",
+                "payload": {"query": 'subscription { exception(message: "TEST EXC") }'},
+            }
+        )
     )

-    response = await ws.receive_json()
-    assert response["type"] == GQL_DATA
-    assert response["id"] == "demo"
-    assert response["payload"]["data"] is None
-    assert response["payload"]["errors"] == [{"message": "TEST EXC"}]
+    data_message: DataMessage = await ws.receive_json()
+    assert data_message["type"] == "data"
+    assert data_message["id"] == "demo"
+    assert data_message["payload"]["data"] is None
+
+    payload_errors = data_message["payload"].get("errors")
+    assert payload_errors is not None
+    assert payload_errors == [{"message": "TEST EXC"}]

-    await ws.send_json({"type": GQL_STOP, "id": "demo"})
-    response = await ws.receive_json()
-    assert response["type"] == GQL_COMPLETE
-    assert response["id"] == "demo"
+    await ws.send_json(StopMessage({"type": "stop", "id": "demo"}))
+    complete_message = await ws.receive_json()
+    assert complete_message["type"] == "complete"
+    assert complete_message["id"] == "demo"

 async def test_subscription_field_error(ws: WebSocketClient):
     await ws.send_json(
-        {
-            "type": GQL_START,
-            "id": "invalid-field",
-            "payload": {"query": "subscription { notASubscriptionField }"},
-        }
+        StartMessage(
+            {
+                "type": "start",
+                "id": "invalid-field",
+                "payload": {"query": "subscription { notASubscriptionField }"},
+            }
+        )
     )

-    response = await ws.receive_json()
-    assert response["type"] == GQL_ERROR
-    assert response["id"] == "invalid-field"
-    assert response["payload"] == {
+    error_message: ErrorMessage = await ws.receive_json()
+    assert error_message["type"] == "error"
+    assert error_message["id"] == "invalid-field"
+    assert error_message["payload"] == {
         "locations": [{"line": 1, "column": 16}],
         "message": (
             "Cannot query field 'notASubscriptionField' on type 'Subscription'."
@@ -265,17 +295,19 @@ async def test_subscription_field_error(ws: WebSocketClient):

 async def test_subscription_syntax_error(ws: WebSocketClient):
     await ws.send_json(
-        {
-            "type": GQL_START,
-            "id": "syntax-error",
-            "payload": {"query": "subscription { example "},
-        }
+        StartMessage(
+            {
+                "type": "start",
+                "id": "syntax-error",
+                "payload": {"query": "subscription { example "},
+            }
+        )
     )

-    response = await ws.receive_json()
-    assert response["type"] == GQL_ERROR
-    assert response["id"] == "syntax-error"
-    assert response["payload"] == {
+    error_message: ErrorMessage = await ws.receive_json()
+    assert error_message["type"] == "error"
+    assert error_message["id"] == "syntax-error"
+    assert error_message["payload"] == {
         "locations": [{"line": 1, "column": 24}],
         "message": "Syntax Error: Expected Name, found <EOF>.",
     }
@@ -284,7 +316,9 @@ async def test_subscription_syntax_error(ws: WebSocketClient):
 async def test_non_text_ws_messages_result_in_socket_closure(ws_raw: WebSocketClient):
     ws = ws_raw

-    await ws.send_bytes(json.dumps({"type": GQL_CONNECTION_INIT}).encode())
+    await ws.send_bytes(
+        json.dumps(ConnectionInitMessage({"type": "connection_init"})).encode()
+    )

     await ws.receive(timeout=2)
     assert ws.closed
@@ -296,36 +330,38 @@ async def test_non_json_ws_messages_are_ignored(ws_raw: WebSocketClient):
     ws = ws_raw

     await ws.send_text("NOT VALID JSON")
-    await ws.send_json({"type": GQL_CONNECTION_INIT})
+    await ws.send_json(ConnectionInitMessage({"type": "connection_init"}))

-    response = await ws.receive_json()
-    assert response["type"] == GQL_CONNECTION_ACK
+    connection_ack_message: ConnectionAckMessage = await ws.receive_json()
+    assert connection_ack_message["type"] == "connection_ack"

     await ws.send_text("NOT VALID JSON")
     await ws.send_json(
-        {
-            "type": GQL_START,
-            "id": "demo",
-            "payload": {
-                "query": 'subscription { echo(message: "Hi") }',
-            },
-        }
+        StartMessage(
+            {
+                "type": "start",
+                "id": "demo",
+                "payload": {
+                    "query": 'subscription { echo(message: "Hi") }',
+                },
+            }
+        )
     )

-    response = await ws.receive_json()
-    assert response["type"] == GQL_DATA
-    assert response["id"] == "demo"
-    assert response["payload"]["data"] == {"echo": "Hi"}
+    data_message = await ws.receive_json()
+    assert data_message["type"] == "data"
+    assert data_message["id"] == "demo"
+    assert data_message["payload"]["data"] == {"echo": "Hi"}

     await ws.send_text("NOT VALID JSON")
-    await ws.send_json({"type": GQL_STOP, "id": "demo"})
+    await ws.send_json(StopMessage({"type": "stop", "id": "demo"}))

-    response = await ws.receive_json()
-    assert response["type"] == GQL_COMPLETE
-    assert response["id"] == "demo"
+    complete_message: CompleteMessage = await ws.receive_json()
+    assert complete_message["type"] == "complete"
+    assert complete_message["id"] == "demo"

     await ws.send_text("NOT VALID JSON")
-    await ws.send_json({"type": GQL_CONNECTION_TERMINATE})
+    await ws.send_json(ConnectionTerminateMessage({"type": "connection_terminate"}))
     await ws.receive(timeout=2)  # receive close
     assert ws.closed

@@ -333,20 +369,22 @@ async def test_non_json_ws_messages_are_ignored(ws_raw: WebSocketClient):
 async def test_ws_message_frame_types_cannot_be_mixed(ws_raw: WebSocketClient):
     ws = ws_raw

-    await ws.send_json({"type": GQL_CONNECTION_INIT})
+    await ws.send_json(ConnectionInitMessage({"type": "connection_init"}))

-    response = await ws.receive_json()
-    assert response["type"] == GQL_CONNECTION_ACK
+    connection_ack_message: ConnectionAckMessage = await ws.receive_json()
+    assert connection_ack_message["type"] == "connection_ack"

     await ws.send_bytes(
         json.dumps(
-            {
-                "type": GQL_START,
-                "id": "demo",
-                "payload": {
-                    "query": 'subscription { echo(message: "Hi") }',
-                },
-            }
+            StartMessage(
+                {
+                    "type": "start",
+                    "id": "demo",
+                    "payload": {
+                        "query": 'subscription { echo(message: "Hi") }',
+                    },
+                }
+            )
         ).encode()
     )

@@ -359,35 +397,38 @@ async def test_ws_message_frame_types_cannot_be_mixed(ws_raw: WebSocketClient):
 async def test_unknown_protocol_messages_are_ignored(ws_raw: WebSocketClient):
     ws = ws_raw
     await ws.send_json({"type": "NotAProtocolMessage"})
-    await ws.send_json({"type": GQL_CONNECTION_INIT})
+    await ws.send_json(ConnectionInitMessage({"type": "connection_init"}))

     await ws.send_json({"type": "NotAProtocolMessage"})
     await ws.send_json(
-        {
-            "type": GQL_START,
-            "id": "demo",
-            "payload": {
-                "query": 'subscription { echo(message: "Hi") }',
-            },
-        }
+        StartMessage(
+            {
+                "type": "start",
+                "id": "demo",
+                "payload": {
+                    "query": 'subscription { echo(message: "Hi") }',
+                },
+            }
+        )
     )

-    response = await ws.receive_json()
-    assert response["type"] == GQL_CONNECTION_ACK
+    connection_ack_message: ConnectionAckMessage = await ws.receive_json()
+    assert connection_ack_message["type"] == "connection_ack"

-    response = await ws.receive_json()
-    assert response["type"] == GQL_DATA
-    assert response["id"] == "demo"
-    assert response["payload"]["data"] == {"echo": "Hi"}
+    data_message = await ws.receive_json()
+    assert data_message["type"] == "data"
+    assert data_message["id"] == "demo"
+    assert data_message["payload"]["data"] == {"echo": "Hi"}

     await ws.send_json({"type": "NotAProtocolMessage"})
-    await ws.send_json({"type": GQL_STOP, "id": "demo"})
-    response = await ws.receive_json()
-    assert response["type"] == GQL_COMPLETE
-    assert response["id"] == "demo"
+    await ws.send_json(StopMessage({"type": "stop", "id": "demo"}))
+
+    complete_message: CompleteMessage = await ws.receive_json()
+    assert complete_message["type"] == "complete"
+    assert complete_message["id"] == "demo"

     await ws.send_json({"type": "NotAProtocolMessage"})
-    await ws.send_json({"type": GQL_CONNECTION_TERMINATE})
+    await ws.send_json(ConnectionTerminateMessage({"type": "connection_terminate"}))

     # make sure the WebSocket is disconnected now
     await ws.receive(timeout=2)  # receive close
@@ -396,56 +437,62 @@ async def test_unknown_protocol_messages_are_ignored(ws_raw: WebSocketClient):

 async def test_custom_context(ws: WebSocketClient):
     await ws.send_json(
-        {
-            "type": GQL_START,
-            "id": "demo",
-            "payload": {
-                "query": "subscription { context }",
-            },
-        }
+        StartMessage(
+            {
+                "type": "start",
+                "id": "demo",
+                "payload": {
+                    "query": "subscription { context }",
+                },
+            }
+        )
     )

-    response = await ws.receive_json()
-    assert response["type"] == GQL_DATA
-    assert response["id"] == "demo"
-    assert response["payload"]["data"] == {"context": "a value from context"}
+    data_message: DataMessage = await ws.receive_json()
+    assert data_message["type"] == "data"
+    assert data_message["id"] == "demo"
+    assert data_message["payload"]["data"] == {"context": "a value from context"}

-    await ws.send_json({"type": GQL_STOP, "id": "demo"})
-    response = await ws.receive_json()
-    assert response["type"] == GQL_COMPLETE
-    assert response["id"] == "demo"
+    await ws.send_json(StopMessage({"type": "stop", "id": "demo"}))
+
+    complete_message: CompleteMessage = await ws.receive_json()
+    assert complete_message["type"] == "complete"
+    assert complete_message["id"] == "demo"

 async def test_resolving_enums(ws: WebSocketClient):
     await ws.send_json(
-        {
-            "type": GQL_START,
-            "id": "demo",
-            "payload": {
-                "query": "subscription { flavors }",
-            },
-        }
+        StartMessage(
+            {
+                "type": "start",
+                "id": "demo",
+                "payload": {
+                    "query": "subscription { flavors }",
+                },
+            }
+        )
     )

-    response = await ws.receive_json()
-    assert response["type"] == GQL_DATA
-    assert response["id"] == "demo"
-    assert response["payload"]["data"] == {"flavors": "VANILLA"}
+    data_message1: DataMessage = await ws.receive_json()
+    assert data_message1["type"] == "data"
+    assert data_message1["id"] == "demo"
+    assert data_message1["payload"]["data"] == {"flavors": "VANILLA"}

-    response = await ws.receive_json()
-    assert response["type"] == GQL_DATA
-    assert response["id"] == "demo"
-    assert response["payload"]["data"] == {"flavors": "STRAWBERRY"}
+    data_message2: DataMessage = await ws.receive_json()
+    assert data_message2["type"] == "data"
+    assert data_message2["id"] == "demo"
+    assert data_message2["payload"]["data"] == {"flavors": "STRAWBERRY"}

-    response = await ws.receive_json()
-    assert response["type"] == GQL_DATA
-    assert response["id"] == "demo"
-    assert response["payload"]["data"] == {"flavors": "CHOCOLATE"}
+    data_message3: DataMessage = await ws.receive_json()
+    assert data_message3["type"] == "data"
+    assert data_message3["id"] == "demo"
+    assert data_message3["payload"]["data"] == {"flavors": "CHOCOLATE"}

-    await ws.send_json({"type": GQL_STOP, "id": "demo"})
-    response = await ws.receive_json()
-    assert response["type"] == GQL_COMPLETE
-    assert response["id"] == "demo"
+    await ws.send_json(StopMessage({"type": "stop", "id": "demo"}))
+
+    complete_message: CompleteMessage = await ws.receive_json()
+    assert complete_message["type"] == "complete"
+    assert complete_message["id"] == "demo"

 @pytest.mark.xfail(reason="flaky test")
@@ -476,8 +523,8 @@ def get_result_handler_tasks():
     )

     async with connection1 as ws1, connection2 as ws2:
-        start_payload = {
-            "type": GQL_START,
+        start_message: StartMessage = {
+            "type": "start",
             "id": "demo",
             "payload": {"query": 'subscription { infinity(message: "Hi") }'},
         }
@@ -486,8 +533,8 @@ def get_result_handler_tasks():
         if aio:
             assert len(get_result_handler_tasks()) == 0

-        await ws1.send_json({"type": GQL_CONNECTION_INIT})
-        await ws1.send_json(start_payload)
+        await ws1.send_json(ConnectionInitMessage({"type": "connection_init"}))
+        await ws1.send_json(start_message)
         await ws1.receive_json()  # ack
         await ws1.receive_json()  # data

@@ -495,8 +542,8 @@ def get_result_handler_tasks():
         if aio:
             assert len(get_result_handler_tasks()) == 1

-        await ws2.send_json({"type": GQL_CONNECTION_INIT})
-        await ws2.send_json(start_payload)
+        await ws2.send_json(ConnectionInitMessage({"type": "connection_init"}))
+        await ws2.send_json(start_message)
         await ws2.receive_json()
         await ws2.receive_json()

@@ -504,14 +551,14 @@ def get_result_handler_tasks():
         if aio:
             assert len(get_result_handler_tasks()) == 2

-        await ws1.send_json({"type": GQL_STOP, "id": "demo"})
+        await ws1.send_json(StopMessage({"type": "stop", "id": "demo"}))
         await ws1.receive_json()  # complete

         # 1 active result handler tasks
         if aio:
             assert len(get_result_handler_tasks()) == 1

-        await ws2.send_json({"type": GQL_STOP, "id": "demo"})
+        await ws2.send_json(StopMessage({"type": "stop", "id": "demo"}))
         await ws2.receive_json()  # complete

         # 0 active result handler tasks
@@ -519,25 +566,29 @@ def get_result_handler_tasks():
             assert len(get_result_handler_tasks()) == 0

         await ws1.send_json(
-            {
-                "type": GQL_START,
-                "id": "debug1",
-                "payload": {
-                    "query": "subscription { debug { numActiveResultHandlers } }",
-                },
-            }
+            StartMessage(
+                {
+                    "type": "start",
+                    "id": "debug1",
+                    "payload": {
+                        "query": "subscription { debug { numActiveResultHandlers } }",
+                    },
+                }
+            )
         )

-        response = await ws1.receive_json()
-        assert response["type"] == GQL_DATA
-        assert response["id"] == "debug1"
+        data_message: DataMessage = await ws1.receive_json()
+        assert data_message["type"] == "data"
+        assert data_message["id"] == "debug1"

         # The one active result handler is the one for this debug subscription
-        assert response["payload"]["data"] == {"debug": {"numActiveResultHandlers": 1}}
+        assert data_message["payload"]["data"] == {
+            "debug": {"numActiveResultHandlers": 1}
+        }

-        response = await ws1.receive_json()
-        assert response["type"] == GQL_COMPLETE
-        assert response["id"] == "debug1"
+        complete_message: CompleteMessage = await ws1.receive_json()
+        assert complete_message["type"] == "complete"
+        assert complete_message["id"] == "debug1"

 async def test_injects_connection_params(aiohttp_app_client: HttpClient):
@@ -545,36 +596,40 @@ async def test_injects_connection_params(aiohttp_app_client: HttpClient):
         "/graphql", protocols=[GRAPHQL_WS_PROTOCOL]
     ) as ws:
         await ws.send_json(
-            {
-                "type": GQL_CONNECTION_INIT,
-                "id": "demo",
-                "payload": {"strawberry": "rocks"},
-            }
+            ConnectionInitMessage(
+                {
+                    "type": "connection_init",
+                    "payload": {"strawberry": "rocks"},
+                }
+            )
         )
         await ws.send_json(
-            {
-                "type": GQL_START,
-                "id": "demo",
-                "payload": {
-                    "query": "subscription { connectionParams }",
-                },
-            }
+            StartMessage(
+                {
+                    "type": "start",
+                    "id": "demo",
+                    "payload": {
+                        "query": "subscription { connectionParams }",
+                    },
+                }
+            )
         )

-        response = await ws.receive_json()
-        assert response["type"] == GQL_CONNECTION_ACK
+        connection_ack_message: ConnectionAckMessage = await ws.receive_json()
+        assert connection_ack_message["type"] == "connection_ack"

-        response = await ws.receive_json()
-        assert response["type"] == GQL_DATA
-        assert response["id"] == "demo"
-        assert response["payload"]["data"] == {"connectionParams": "rocks"}
+        data_message: DataMessage = await ws.receive_json()
+        assert data_message["type"] == "data"
+        assert data_message["id"] == "demo"
+        assert data_message["payload"]["data"] == {"connectionParams": "rocks"}

-        await ws.send_json({"type": GQL_STOP, "id": "demo"})
-        response = await ws.receive_json()
-        assert response["type"] == GQL_COMPLETE
-        assert response["id"] == "demo"
+        await ws.send_json(StopMessage({"type": "stop", "id": "demo"}))

-        await ws.send_json({"type": GQL_CONNECTION_TERMINATE})
+        complete_message: CompleteMessage = await ws.receive_json()
+        assert complete_message["type"] == "complete"
+        assert complete_message["id"] == "demo"
+
+        await ws.send_json(ConnectionTerminateMessage({"type": "connection_terminate"}))

         # make sure the WebSocket is disconnected now
         await ws.receive(timeout=2)  # receive close
@@ -587,14 +642,14 @@ async def test_rejects_connection_params(aiohttp_app_client: HttpClient):
     ) as ws:
         await ws.send_json(
             {
-                "type": GQL_CONNECTION_INIT,
+                "type": "connection_init",
                 "id": "demo",
                 "payload": "gonna fail",
             }
         )

-        response = await ws.receive_json()
-        assert response["type"] == GQL_CONNECTION_ERROR
+        connection_error_message: ConnectionErrorMessage = await ws.receive_json()
+        assert connection_error_message["type"] == "connection_error"

         # make sure the WebSocket is disconnected now
         await ws.receive(timeout=2)  # receive close
@@ -608,28 +663,30 @@ async def test_no_extensions_results_wont_send_extensions_in_payload(
     async with aiohttp_app_client.ws_connect(
         "/graphql", protocols=[GRAPHQL_WS_PROTOCOL]
     ) as ws:
-        await ws.send_json({"type": GQL_CONNECTION_INIT})
+        await ws.send_json(ConnectionInitMessage({"type": "connection_init"}))
         await ws.send_json(
-            {
-                "type": GQL_START,
-                "id": "demo",
-                "payload": {
-                    "query": 'subscription { echo(message: "Hi") }',
-                },
-            }
+            StartMessage(
+                {
+                    "type": "start",
+                    "id": "demo",
+                    "payload": {
+                        "query": 'subscription { echo(message: "Hi") }',
+                    },
+                }
+            )
         )

-        response = await ws.receive_json()
-        assert response["type"] == GQL_CONNECTION_ACK
+        connection_ack_message = await ws.receive_json()
+        assert connection_ack_message["type"] == "connection_ack"

-        response = await ws.receive_json()
+        data_message: DataMessage = await ws.receive_json()
         mock.assert_called_once()
-        assert response["type"] == GQL_DATA
-        assert response["id"] == "demo"
-        assert "extensions" not in response["payload"]
+        assert data_message["type"] == "data"
+        assert data_message["id"] == "demo"
+        assert "extensions" not in data_message["payload"]

-        await ws.send_json({"type": GQL_STOP, "id": "demo"})
-        response = await ws.receive_json()
+        await ws.send_json(StopMessage({"type": "stop", "id": "demo"}))
+        await ws.receive_json()

 async def test_unexpected_client_disconnects_are_gracefully_handled(
@@ -639,18 +696,21 @@ async def test_unexpected_client_disconnects_are_gracefully_handled(
     process_errors = mock.Mock()

     with mock.patch.object(Schema, "process_errors", process_errors):
-        await ws.send_json({"type": GQL_CONNECTION_INIT})
-        response = await ws.receive_json()
-        assert response["type"] == GQL_CONNECTION_ACK
+        await ws.send_json(ConnectionInitMessage({"type": "connection_init"}))
+
+        connection_ack_message: ConnectionAckMessage = await ws.receive_json()
+        assert connection_ack_message["type"] == "connection_ack"

         await ws.send_json(
-            {
-                "type": GQL_START,
-                "id": "sub1",
-                "payload": {
-                    "query": 'subscription { echo(message: "Hi", delay: 0.5) }',
-                },
-            }
+            StartMessage(
+                {
+                    "type": "start",
+                    "id": "sub1",
+                    "payload": {
+                        "query": 'subscription { echo(message: "Hi", delay: 0.5) }',
+                    },
+                }
+            )
         )

         await ws.close()

Refactor graphql-ws message types
Both the legacy `graphql-ws` and the newer `graphql-transport-ws` protocols are concerned with the processing of a handful of messages.

Our implementation of the newer protocol uses dataclasses to describe messages and their `type`. They are very pleasant to work with and look like this:

https://github.com/strawberry-graphql/strawberry/blob/b569bf79d5863731aeb7996f073c795616b473e5/strawberry/subscriptions/protocols/graphql_transport_ws/types.py#L17-L24

However, our implementation of the legacy protocol uses `TypedDict` to describe messages and separate constants for message types. In our case they are harder to work with.

We should refactor the legacy protocols message definitions and use dataclasses for them as well.

## Upvote & Fund

- We're using [Polar.sh](https://polar.sh/strawberry-graphql) so you can upvote and help fund this issue.
- We receive the funding once the issue is completed & confirmed by you.
- Thank you in advance for helping prioritize & fund our backlog.

<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/strawberry-graphql/strawberry/issues/1669/pledge.svg?darkmode=1">

</picture>

Refactor graphql ws message types
Shown in issue #1669 the changes requested was to refactor the use of TypedDict to the use of dataclasses for the graphql_ws component. Three classes were changed in this set: StartPayload, DataPayload, and OperationMessage.

## Description

The changes done in the PR are as follows:
1. Changed StartPayload to a dataclass and added the asdict method to it via the use of the GraphQLTransportMessage dataclass
2. Changed DataPayload to a dataclass and added the asdict method to it via the use of the GraphQLTransportMessage dataclass
3. Changed OperationMessage to a dataclass and added the asdict method to it via the use of the GraphQLTransportMessage dataclass

## Types of Changes

- [ ] Core
- [ ] Bugfix
- [ ] New feature
- [x] Enhancement/optimization
- [ ] Documentation

## Issues Fixed or Closed by This PR

* #1669

## Checklist

- [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).

Someone asked on Discord for clarification of the following statement:

> In our case they are harder to work with.

Dataclasses allow us to store the message `type` within the dataclass. With typedicts we have to store the `type` of a message in a separate constant, creating an _implicit_ dependency between the typedict and the separate constant. When working with graphql-ws messages we have to import both the typedicts and the constants. In this case the typedict-approach results in more imports and is more error prone due to the implicit dependencies between typedicts and constants.
@DoctorJohn I wonder when we should drop support for the legacy protocol 😊
> @DoctorJohn I wonder when we should drop support for the legacy protocol 😊

Me too. The [corresponding js library](https://www.npmjs.com/package/subscriptions-transport-ws) still has more than 2 million weekly downloads. So I assume it's still in use.
Thanks for adding the release file! Unfortunately it does seem to be invalid. Make sure it looks like the following example:

```markdown
Release type: patch

Description of the changes, ideally with some examples, if adding a new feature.
```

Here's the tweet text:
```
🆕 Release (next) is out! Thanks to Matthew Jones for the PR 👏

Get it here 👉 https://github.com/strawberry-graphql/strawberry/releases/tag/(next)
```
<!-- action-check: release-file -->


diff --git a/django/http/response.py b/django/http/response.py
index 1dbaf46adda4..4a0ea6701375 100644
--- a/django/http/response.py
+++ b/django/http/response.py
@@ -627,10 +627,12 @@ def set_headers(self, filelike):
 class HttpResponseRedirectBase(HttpResponse):
     allowed_schemes = ["http", "https", "ftp"]

-    def __init__(self, redirect_to, *args, **kwargs):
+    def __init__(self, redirect_to, preserve_request=False, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self["Location"] = iri_to_uri(redirect_to)
         parsed = urlsplit(str(redirect_to))
+        if preserve_request:
+            self.status_code = self.status_code_preserve_request
         if parsed.scheme and parsed.scheme not in self.allowed_schemes:
             raise DisallowedRedirect(
                 "Unsafe redirect to URL with protocol '%s'" % parsed.scheme
@@ -652,10 +654,12 @@ def __repr__(self):

 class HttpResponseRedirect(HttpResponseRedirectBase):
     status_code = 302
+    status_code_preserve_request = 307

 class HttpResponsePermanentRedirect(HttpResponseRedirectBase):
     status_code = 301
+    status_code_preserve_request = 308

 class HttpResponseNotModified(HttpResponse):
diff --git a/django/shortcuts.py b/django/shortcuts.py
index b8b5be1f5f54..6274631dbad7 100644
--- a/django/shortcuts.py
+++ b/django/shortcuts.py
@@ -26,7 +26,7 @@ def render(
     return HttpResponse(content, content_type, status)

-def redirect(to, *args, permanent=False, **kwargs):
+def redirect(to, *args, permanent=False, preserve_request=False, **kwargs):
     """
     Return an HttpResponseRedirect to the appropriate URL for the arguments
     passed.
@@ -40,13 +40,17 @@ def redirect(to, *args, permanent=False, **kwargs):

         * A URL, which will be used as-is for the redirect location.

-    Issues a temporary redirect by default; pass permanent=True to issue a
-    permanent redirect.
+    Issues a temporary redirect by default. Set permanent=True to issue a
+    permanent redirect. Set preserve_request=True to instruct the user agent
+    to preserve the original HTTP method and body when following the redirect.
     """
     redirect_class = (
         HttpResponsePermanentRedirect if permanent else HttpResponseRedirect
     )
-    return redirect_class(resolve_url(to, *args, **kwargs))
+    return redirect_class(
+        resolve_url(to, *args, **kwargs),
+        preserve_request=preserve_request,
+    )

 def _get_queryset(klass):
diff --git a/docs/ref/request-response.txt b/docs/ref/request-response.txt
index afebd00d8b89..26fcb5fa0824 100644
--- a/docs/ref/request-response.txt
+++ b/docs/ref/request-response.txt
@@ -1070,18 +1070,32 @@ types of HTTP responses. Like ``HttpResponse``, these subclasses live in
     (e.g. ``'https://www.yahoo.com/search/'``), an absolute path with no domain
     (e.g. ``'/search/'``), or even a relative path (e.g. ``'search/'``). In that
     last case, the client browser will reconstruct the full URL itself
-    according to the current path. See :class:`HttpResponse` for other optional
-    constructor arguments. Note that this returns an HTTP status code 302.
+    according to the current path.
+
+    The constructor accepts an optional ``preserve_request`` keyword argument
+    that defaults to ``False``, producing a response with a 302 status code. If
+    ``preserve_request`` is ``True``, the status code will be 307 instead.
+
+    See :class:`HttpResponse` for other optional constructor arguments.

     .. attribute:: HttpResponseRedirect.url

         This read-only attribute represents the URL the response will redirect
         to (equivalent to the ``Location`` response header).

+    .. versionchanged:: 5.2
+
+        The ``preserve_request`` argument was added.
+
 .. class:: HttpResponsePermanentRedirect

     Like :class:`HttpResponseRedirect`, but it returns a permanent redirect
     (HTTP status code 301) instead of a "found" redirect (status code 302).
+    When ``preserve_request=True``, the response's status code is 308.
+
+    .. versionchanged:: 5.2
+
+        The ``preserve_request`` argument was added.

 .. class:: HttpResponseNotModified

diff --git a/docs/releases/5.2.txt b/docs/releases/5.2.txt
index 88a1daa45dc7..0ee4868246e1 100644
--- a/docs/releases/5.2.txt
+++ b/docs/releases/5.2.txt
@@ -294,6 +294,16 @@ Requests and Responses
 * The new :meth:`.HttpRequest.get_preferred_type` method can be used to query
   the preferred media type the client accepts.

+* The new ``preserve_request`` argument for
+  :class:`~django.http.HttpResponseRedirect` and
+  :class:`~django.http.HttpResponsePermanentRedirect`
+  determines whether the HTTP status codes 302/307 or 301/308 are used,
+  respectively.
+
+* The new ``preserve_request`` argument for
+  :func:`~django.shortcuts.redirect` allows to instruct the user agent to reuse
+  the HTTP method and body during redirection using specific status codes.
+
 Security
 ~~~~~~~~

diff --git a/docs/topics/http/shortcuts.txt b/docs/topics/http/shortcuts.txt
index 171cfc3c9353..308eae085522 100644
--- a/docs/topics/http/shortcuts.txt
+++ b/docs/topics/http/shortcuts.txt
@@ -91,7 +91,7 @@ This example is equivalent to::
 ``redirect()``
 ==============

-.. function:: redirect(to, *args, permanent=False, **kwargs)
+.. function:: redirect(to, *args, permanent=False, preserve_request=False, **kwargs)

    Returns an :class:`~django.http.HttpResponseRedirect` to the appropriate URL
    for the arguments passed.
@@ -107,8 +107,27 @@ This example is equivalent to::
    * An absolute or relative URL, which will be used as-is for the redirect
      location.

-   By default issues a temporary redirect; pass ``permanent=True`` to issue a
-   permanent redirect.
+   By default, a temporary redirect is issued with a 302 status code. If
+   ``permanent=True``, a permanent redirect is issued with a 301 status code.
+
+   If ``preserve_request=True``, the response instructs the user agent to
+   preserve the method and body of the original request when issuing the
+   redirect. In this case, temporary redirects use a 307 status code, and
+   permanent redirects use a 308 status code. This is better illustrated in the
+   following table:
+
+   =========  ================ ================
+   permanent  preserve_request HTTP status code
+   =========  ================ ================
+   ``True``   ``False``        301
+   ``False``  ``False``        302
+   ``False``  ``True``         307
+   ``True``   ``True``         308
+   =========  ================ ================
+
+   .. versionchanged:: 5.2
+
+       The argument ``preserve_request`` was added.

 Examples
 --------
@@ -158,6 +177,17 @@ will be returned::
         obj = MyModel.objects.get(...)
         return redirect(obj, permanent=True)

+Additionally, the ``preserve_request`` argument can be used to preserve the
+original HTTP method::
+
+    def my_view(request):
+        # ...
+        obj = MyModel.objects.get(...)
+        if request.method in ("POST", "PUT"):
+            # Redirection preserves the original request method.
+            return redirect(obj, preserve_request=True)
+        # ...
+
 ``get_object_or_404()``
 =======================

diff --git a/tests/httpwrappers/tests.py b/tests/httpwrappers/tests.py
index 3774ff2d6727..f85d33e82338 100644
--- a/tests/httpwrappers/tests.py
+++ b/tests/httpwrappers/tests.py
@@ -566,6 +566,27 @@ def test_redirect_lazy(self):
         r = HttpResponseRedirect(lazystr("/redirected/"))
         self.assertEqual(r.url, "/redirected/")

+    def test_redirect_modifiers(self):
+        cases = [
+            (HttpResponseRedirect, "Moved temporarily", False, 302),
+            (HttpResponseRedirect, "Moved temporarily preserve method", True, 307),
+            (HttpResponsePermanentRedirect, "Moved permanently", False, 301),
+            (
+                HttpResponsePermanentRedirect,
+                "Moved permanently preserve method",
+                True,
+                308,
+            ),
+        ]
+        for response_class, content, preserve_request, expected_status_code in cases:
+            with self.subTest(status_code=expected_status_code):
+                response = response_class(
+                    "/redirected/", content=content, preserve_request=preserve_request
+                )
+                self.assertEqual(response.status_code, expected_status_code)
+                self.assertEqual(response.content.decode(), content)
+                self.assertEqual(response.url, response.headers["Location"])
+
     def test_redirect_repr(self):
         response = HttpResponseRedirect("/redirected/")
         expected = (
diff --git a/tests/shortcuts/tests.py b/tests/shortcuts/tests.py
index 8e9c13d20620..b80b8f595139 100644
--- a/tests/shortcuts/tests.py
+++ b/tests/shortcuts/tests.py
@@ -1,3 +1,5 @@
+from django.http.response import HttpResponseRedirectBase
+from django.shortcuts import redirect
 from django.test import SimpleTestCase, override_settings
 from django.test.utils import require_jinja2

@@ -35,3 +37,22 @@ def test_render_with_using(self):
         self.assertEqual(response.content, b"DTL\n")
         response = self.client.get("/render/using/?using=jinja2")
         self.assertEqual(response.content, b"Jinja2\n")
+
+
+class RedirectTests(SimpleTestCase):
+    def test_redirect_response_status_code(self):
+        tests = [
+            (True, False, 301),
+            (False, False, 302),
+            (False, True, 307),
+            (True, True, 308),
+        ]
+        for permanent, preserve_request, expected_status_code in tests:
+            with self.subTest(permanent=permanent, preserve_request=preserve_request):
+                response = redirect(
+                    "/path/is/irrelevant/",
+                    permanent=permanent,
+                    preserve_request=preserve_request,
+                )
+                self.assertIsInstance(response, HttpResponseRedirectBase)
+                self.assertEqual(response.status_code, expected_status_code)

Add 307 and 308 redirect response codes to django.shortcuts.redirect
Description

Other than 301 and 302 response codes for redirects, there is also:
https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/307
https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/308
Currently, Django is unaware of these.
Proposal:
Add two new HTTP response classes for 307 and 308.
Enhance django.shortcuts.redirect with a new keyword argument preserve_method that, in combination with the existing permanent, decides which of the four redirect classes to use.
Add 307 and 308 redirect response codes to django.shortcuts.redirect
Description

Other than 301 and 302 response codes for redirects, there is also:
https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/307
https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/308
Currently, Django is unaware of these.
Proposal:
Add two new HTTP response classes for 307 and 308.
Enhance django.shortcuts.redirect with a new keyword argument preserve_method that, in combination with the existing permanent, decides which of the four redirect classes to use.

['\u200bhttps://github.com/django/django/pull/18616 Proof of concept until the ticket itself is approved. After that tests and documentation will be updated.', 1727144938.0]
['\u200bhttps://github.com/django/django/pull/18616 Proof of concept until the ticket itself is approved. After that tests and documentation will be updated.', 1727144938.0]