Graphene + Django GraphQL subscriptions over Django Channels (async WebSockets, bounded outbox, multi-operation registry).
Project description
CypartaGraphqlSubscriptionsTools
Version 4.1.5
Graphene + Django GraphQL subscriptions over Django Channels (async WebSockets). The package ships a production-oriented consumer that speaks:
- graphql-transport-ws — negotiate with WebSocket subprotocol
graphql-transport-ws(recommended). - Legacy graphql-ws — subprotocol
graphql-ws(Apollo-stylestart/stop/data).
The server selects the protocol from the Sec-WebSocket-Protocol header. Your GraphQL schema, models, and routing live in your Django project — not inside this library.
Architecture overview
Features
- WebSocket GraphQL subscriptions — JSON messages,
connection_initgating, per-operationid, GraphQL errors over the wire. - Per-connection bounded outbox —
asyncio.Queueplus one sender task so slow clients cannot grow an unbounded send backlog (CYPARTA_WS_OUTBOX_MAXSIZE). - Multi-operation support — each
subscribe/startoperation has its ownid; channel groups map to operation ids (_ops/_group_ops). - Optional group permission class — dotted path to a class with
has_permission(...)for per-group authorization (CYPARTA_WS_GROUP_PERMISSION_CLASS). - Group name validation — names validated like Django Channels
BaseChannelLayer.valid_group_name(validate_group_name,CYPARTA_WS_STRICT_GROUP_NAMES). - Optional model lifecycle mixin —
CypartaSubscriptionModelMixinschedulestrigger_subscriptionontransaction.on_commit(create / update / delete hooks viadjango_lifecycle). - Custom event serializer — optional
CYPARTA_WS_EVENT_SERIALIZERcallable for server-sidetrigger_subscriptionpayloads; import path cached untilreset_event_serializer_cache(). - Outbox overflow strategy — when the queue is full:
drop_newest(default),drop_oldest, orclose_connection(WebSocket close code 4413).
Installation
From PyPI:
pip install cypartagraphqlsubscriptionstools
From a git checkout (editable):
pip install -e .
Install test dependencies:
pip install -e ".[test]"
Quick start
1. Register the app
# settings.py
INSTALLED_APPS = [
# ...
"channels",
"CypartaGraphqlSubscriptionsTools",
"articles", # your app
]
2. Channel layers
In-memory (development / tests):
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels.layers.InMemoryChannelLayer",
},
}
Redis (typical production):
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [("127.0.0.1", 6379)],
},
},
}
Install channels-redis when using the Redis backend.
3. Graphene schema
Point Django Graphene at your schema object (define Query, Subscription, and schema in your project — for example articles.schema):
# settings.py
GRAPHENE = {
"SCHEMA": "articles.schema.schema",
}
4. ASGI: HTTP + WebSocket consumer
Mount CypartaGraphqlSubscriptionsConsumer on a URL your clients use (here /ws/graphql/).
# articles/routing.py
from django.urls import re_path
from CypartaGraphqlSubscriptionsTools.consumers import CypartaGraphqlSubscriptionsConsumer
websocket_urlpatterns = [
re_path(r"^ws/graphql/$", CypartaGraphqlSubscriptionsConsumer.as_asgi()),
]
# asgi.py
import os
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application
from articles.routing import websocket_urlpatterns
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
django_asgi_app = get_asgi_application()
application = ProtocolTypeRouter(
{
"http": django_asgi_app,
"websocket": AuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
}
)
Set ASGI_APPLICATION = "myproject.asgi.application" in settings.py.
Recommended production settings
Use stricter defaults in production unless you have a deliberate reason not to:
# settings.py
CYPARTA_WS_REQUIRE_AUTH = True
CYPARTA_WS_STRICT_GROUP_NAMES = True
CYPARTA_WS_DROP_EVENT_ON_SERIALIZATION_ERROR = True
CYPARTA_WS_GROUP_PERMISSION_CLASS = "articles.permissions.SubscriptionGroupPermission"
# Dashboards / live metrics: prefer latest snapshot when client lags
CYPARTA_WS_OUTBOX_OVERFLOW_STRATEGY = "drop_oldest"
# Chat / notifications: keep older queued messages; drop only newest on overflow (default)
# CYPARTA_WS_OUTBOX_OVERFLOW_STRATEGY = "drop_newest"
# Shed pathological slow clients (disconnect with code 4413 when outbox stays full)
# CYPARTA_WS_OUTBOX_OVERFLOW_STRATEGY = "close_connection"
Settings reference
| Setting | Role |
|---|---|
CYPARTA_WS_OUTBOX_MAXSIZE |
Max queued outbound ExecutionResult frames per WebSocket (default 256). When full, overflow policy applies. |
CYPARTA_WS_OUTBOX_OVERFLOW_STRATEGY |
drop_newest (default): refuse enqueue of the newest event. drop_oldest: remove oldest queued item then enqueue. close_connection: schedule one close per socket with code 4413. |
CYPARTA_WS_REQUIRE_AUTH |
If True (default), anonymous or missing scope["user"] cannot join subscription groups. |
CYPARTA_WS_GROUP_PERMISSION_CLASS |
Optional dotted path to a permission class (instantiated once per connection) implementing has_permission(self, user, group_name, operation_id=None, scope=None, variables=None) (sync or async). |
CYPARTA_WS_STRICT_GROUP_NAMES |
If True (default), invalid group strings raise a generic GraphQL error. If False, unsafe characters are normalized to _ where possible. |
CYPARTA_WS_EVENT_SERIALIZER |
Optional dotted path: (value, group=None, scope=None) returning the wire payload (sync, async, or awaitable). |
CYPARTA_WS_DROP_EVENT_ON_SERIALIZATION_ERROR |
If True, failed custom + default serialization skips group_send for that event (logged). |
CYPARTA_WS_RAISE_ON_INVALID_TRIGGER_GROUP |
If True, trigger_subscription raises GroupNameInvalid on bad names instead of skipping. |
Outbox strategy hints
- Dashboards / live metrics — often use
drop_oldestso the socket keeps newer updates when the client falls behind. - Chat / notifications — often keep
drop_newestso older queued messages are not discarded silently. close_connection— disconnect slow clients (code 4413) so they reconnect rather than staying in a bad state.
Complete example: Articles app
Below, articles is your Django app. Adjust imports and model fields to your project.
Model (articles/models.py)
from django.conf import settings
from django.db import models
from django_lifecycle import LifecycleModelMixin
from CypartaGraphqlSubscriptionsTools.mixins import CypartaSubscriptionModelMixin
class Article(LifecycleModelMixin, CypartaSubscriptionModelMixin, models.Model):
title = models.CharField(max_length=255)
body = models.TextField(blank=True)
is_public = models.BooleanField(default=False)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="articles",
)
def get_subscription_group_names(self, action):
if action == "create":
return ["ArticleCreated"]
if action == "update":
return [f"ArticleUpdated.{self.pk}"]
if action == "delete":
return [f"ArticleDeleted.{self.pk}"]
return []
def get_subscription_payload(self, action):
return self
GraphQL types and subscriptions (articles/schema.py)
import graphene
from asgiref.sync import async_to_sync
from graphene_django.types import DjangoObjectType
from .models import Article
class ArticleType(DjangoObjectType):
class Meta:
model = Article
class ArticleSubscription(graphene.ObjectType):
"""Subscription root fields; ``root`` in resolvers is the WebSocket consumer."""
article_created = graphene.Field(ArticleType, subscribe=graphene.Boolean())
def resolve_article_created(root, info, subscribe=True):
node = info.field_nodes[0]
selections = node.selection_set.selections if node.selection_set else ()
requested_fields = [
sel.name.value for sel in selections if hasattr(sel, "name")
]
return async_to_sync(root.detect_register_group_status)(
["ArticleCreated"],
requested_fields=requested_fields,
variables=info.variable_values,
subscribe=subscribe,
)
article_updated = graphene.Field(
ArticleType,
id=graphene.String(required=True),
subscribe=graphene.Boolean(),
)
def resolve_article_updated(root, info, id, subscribe=True):
node = info.field_nodes[0]
selections = node.selection_set.selections if node.selection_set else ()
requested_fields = [
sel.name.value for sel in selections if hasattr(sel, "name")
]
return async_to_sync(root.detect_register_group_status)(
[f"ArticleUpdated.{id}"],
requested_fields=requested_fields,
variables=info.variable_values,
subscribe=subscribe,
)
article_deleted = graphene.Field(
ArticleType,
id=graphene.String(required=True),
subscribe=graphene.Boolean(),
)
def resolve_article_deleted(root, info, id, subscribe=True):
node = info.field_nodes[0]
selections = node.selection_set.selections if node.selection_set else ()
requested_fields = [
sel.name.value for sel in selections if hasattr(sel, "name")
]
return async_to_sync(root.detect_register_group_status)(
[f"ArticleDeleted.{id}"],
requested_fields=requested_fields,
variables=info.variable_values,
subscribe=subscribe,
)
class Query(graphene.ObjectType):
hello = graphene.String()
def resolve_hello(self, info):
return "world"
class Subscription(ArticleSubscription, graphene.ObjectType):
pass
schema = graphene.Schema(query=Query, subscription=Subscription)
Note: Prefer the subscribe keyword (GraphQL argument and/or detect_register_group_status(..., subscribe=...)). The legacy positional subscripe flag is still accepted on detect_register_group_status, register_group, and un_register_group for backward compatibility; if you use subscripe without subscribe, extensions.cyparta may include deprecationNotes.
Permission class example
has_permission receives the Django user from scope["user"], the validated channel group name, the GraphQL operation id, the ASGI scope, and subscription variables (when provided).
# articles/permissions.py
from channels.db import database_sync_to_async
from django.contrib.auth.models import AnonymousUser
from .models import Article
class SubscriptionGroupPermission:
async def has_permission(self, user, group_name, operation_id=None, scope=None, variables=None):
if user is None or isinstance(user, AnonymousUser) or getattr(user, "is_anonymous", True):
return False
if group_name == "ArticleCreated":
return True
if group_name.startswith("ArticleUpdated.") or group_name.startswith("ArticleDeleted."):
_prefix, pk_str = group_name.split(".", 1) # e.g. ArticleUpdated.7 -> pk_str == "7"
try:
pk = int(pk_str)
except ValueError:
return False
article = await database_sync_to_async(
lambda: Article.objects.filter(pk=pk).first()
)()
if article is None:
return False
if article.is_public:
return True
return article.owner_id == getattr(user, "pk", None)
return False
# settings.py
CYPARTA_WS_GROUP_PERMISSION_CLASS = "articles.permissions.SubscriptionGroupPermission"
Custom event serializer example
Normalize or redact payloads before group_send. Signature: value, optional group, optional scope (sync or async).
# articles/ws_serializers.py
def serialize_subscription_event(value, group=None, scope=None):
if hasattr(value, "_meta") and hasattr(value, "pk"):
return {"kind": "article", "id": value.pk, "title": getattr(value, "title", "")}
if isinstance(value, dict):
return value
return str(value)
# settings.py
CYPARTA_WS_EVENT_SERIALIZER = "articles.ws_serializers.serialize_subscription_event"
CYPARTA_WS_DROP_EVENT_ON_SERIALIZATION_ERROR = True
The serializer import is cached by dotted path. After changing CYPARTA_WS_EVENT_SERIALIZER in tests or at runtime, call:
from CypartaGraphqlSubscriptionsTools import events
events.reset_event_serializer_cache()
Manual publishing
From synchronous code (views, signals, management commands), publish to every socket in a channel group:
from asgiref.sync import async_to_sync
from CypartaGraphqlSubscriptionsTools.events import trigger_subscription
from .models import Article
def notify_article_updated(article: Article):
async_to_sync(trigger_subscription)(f"ArticleUpdated.{article.pk}", article)
trigger_subscription validates the group name, serializes value (custom serializer if configured), then group_send. Use validate_group_name if you need to pre-validate strings in your own code.
Client examples
Subprotocol must be requested by the client (Sec-WebSocket-Protocol). All frames are JSON objects.
graphql-transport-ws
connection_init— must be sent first; server repliesconnection_ack.subscribe— includesidandpayload(query, optionalvariables, optionaloperationName).- Registration ack — first
nextframe often hasdata: nullandextensions.cyparta(action,registeredGroups,subscribe, …). - Live event —
nextwithpayload.datashaped as{ "<responseKey>": <serialized value> }(response key = field alias or field name). complete— client stops the operation; server may also sendcompletewith the sameid.
Example sequence (illustrative payloads):
{"type": "connection_init"}
{"type": "connection_ack"}
{
"id": "1",
"type": "subscribe",
"payload": {
"query": "subscription { articleUpdated(id: \"5\") { id title } }",
"variables": {}
}
}
{
"id": "1",
"type": "next",
"payload": {
"data": null,
"errors": null,
"extensions": {
"cyparta": {
"action": "register",
"registeredGroups": ["ArticleUpdated.5"],
"subscribe": true,
"subscripe": true
}
}
}
}
{
"id": "1",
"type": "next",
"payload": {
"data": {
"articleUpdated": {
"pk": 5,
"fields": {"id": 5, "title": "Hello", "body": "..."},
"group": "ArticleUpdated.5"
}
},
"errors": null
}
}
{"id": "1", "type": "complete"}
Legacy graphql-ws
Same ordering rule: connection_init before start.
{"type": "connection_init"}
{"type": "connection_ack"}
{
"id": "1",
"type": "start",
"payload": {
"query": "subscription { articleUpdated(id: \"5\") { id title } }",
"variables": {}
}
}
{"id": "1", "type": "stop"}
{"type": "connection_terminate"}
Legacy protocol uses data instead of next for result frames; errors use a single error object in payload instead of an array.
Group names
Rules align with Django Channels BaseChannelLayer.valid_group_name:
- Type: non-empty string only at the API boundary.
- Length: strictly less than the Channels maximum name length.
- Characters: ASCII
a-z,A-Z,0-9,_,-,.only whenCYPARTA_WS_STRICT_GROUP_NAMESisTrue(default).
| Example | Valid? |
|---|---|
ArticleCreated |
Yes |
ArticleUpdated.42 |
Yes |
my-feed_v1 |
Yes |
| `` (empty) | No |
Article Updated (space) |
No (strict); normalized with underscores if strict is False |
bad/slash |
No (strict) |
In Python:
from CypartaGraphqlSubscriptionsTools.utils import GroupNameInvalid, validate_group_name
try:
safe = validate_group_name("ArticleUpdated.1")
except GroupNameInvalid as exc:
# exc.client_message is safe for clients
...
Authentication
The consumer reads scope["user"] for CYPARTA_WS_REQUIRE_AUTH and for CYPARTA_WS_GROUP_PERMISSION_CLASS.
Wrap WebSocket URLs in AuthMiddlewareStack so Channels populates user (see Quick start).
Token in query string or header: implement thin ASGI middleware that copies scope, resolves user (e.g. with channels.db.database_sync_to_async), sets scope["user"], then delegates to the inner application. Compose it with AuthMiddlewareStack according to whether you still need session/cookie auth; see the Channels authentication docs.
Example sketch (query param ?token=... — replace lookup with your Authorization: Token <key> parsing if you use headers):
# articles/middleware.py
from urllib.parse import parse_qs
from channels.db import database_sync_to_async
from django.contrib.auth.models import AnonymousUser
@database_sync_to_async
def user_for_token(key: str):
if not key:
return AnonymousUser()
from rest_framework.authtoken.models import Token
try:
return Token.objects.select_related("user").get(key=key).user
except Token.DoesNotExist:
return AnonymousUser()
class QueryStringTokenMiddleware:
def __init__(self, inner):
self.inner = inner
async def __call__(self, scope, receive, send):
if scope["type"] == "websocket":
query = parse_qs(scope.get("query_string", b"").decode())
token = (query.get("token") or [None])[0]
scope = dict(scope)
scope["user"] = await user_for_token(token)
return await self.inner(scope, receive, send)
For Authorization: Token <key>, decode dict(scope["headers"]) (byte keys/values), find b"authorization", strip the Token prefix, and resolve the user the same way.
Payload filtering (requested_fields)
When trigger_subscription sends a dict whose fields value is itself a dict (the usual shape for serialized Model instances), the consumer can narrow fields to the keys the client selected on the subscription field.
- Pass
requested_fieldnames as a list of strings from the subscription resolver (see Articles example). - If
requested_fieldsisNoneor empty, or the payload has no dictfields, the payload is returned unchanged. - Filtering never mutates the original dict; clients still receive top-level keys like
pkunchanged when onlyfieldsis subset.
Troubleshooting
| Symptom | What to check |
|---|---|
| Socket connects but subscription does nothing | connection_init must run before subscribe / start. Confirm GRAPHENE["SCHEMA"], subscription field names, and group_send group strings match register_group names exactly. |
| Close 1002 right after connect | Unsupported or missing Sec-WebSocket-Protocol. Client must request graphql-transport-ws or graphql-ws. |
| Close 4401 | subscribe / start sent before connection_ack (init not completed). |
| Close 4429 | Duplicate connection_init on the same WebSocket. |
| Close 4413 | Outbox overflow with CYPARTA_WS_OUTBOX_OVERFLOW_STRATEGY = "close_connection"; client is too slow or CYPARTA_WS_OUTBOX_MAXSIZE is too small for your burst rate. |
No event after trigger_subscription |
No subscriber in that exact group name; channel_layer / Redis misconfiguration; serialization dropped the event (CYPARTA_WS_DROP_EVENT_ON_SERIALIZATION_ERROR); invalid group name skipped or raised per CYPARTA_WS_RAISE_ON_INVALID_TRIGGER_GROUP. |
Upgrade notes
- v4.1.5 — README overhaul (quick start, production settings, Articles example, permissions, serializers, clients, troubleshooting). Cover and architecture images;
MANIFEST.inshipscover.jpg/graph.jpgwith the sdist. PyPI-friendly image URLs in README. - v4.1.4 —
drop_oldestcallstask_done()after discarding the oldest queue item (consistent unfinished count forjoin()).close_connectionschedules at most one disconnect per socket._safe_passthroughstringifies dict keys for JSON-safe payloads. README and test extras refined (pytest-djangofor DB tests). - v4.1.3 — Cached
CYPARTA_WS_EVENT_SERIALIZERimport;CYPARTA_WS_DROP_EVENT_ON_SERIALIZATION_ERROR;get_subscription_payload(action)on the mixin; per-grouptry/exceptaround publishes;CYPARTA_WS_OUTBOX_OVERFLOW_STRATEGY. - v4.1.2 — Prefer
subscribe=without positionalsubscripe; mixin publisheson_commit;after_deletefor deletes; optional event serializer andCYPARTA_WS_RAISE_ON_INVALID_TRIGGER_GROUP. - v4.1.1 —
validate_group_name/GroupNameInvalid;subscribekwarg on register helpers; duplicateconnection_initcloses with 4429. - v4.1.0 — Breaking: removed in-package demo model and
CypartaGraphqlSubscriptionsTools.schema; defineQuery/Subscription/ schema** in your project. Permission class is dotted path + **has_permission** instance API. All-or-nothing group registration per call. **filter_requested_fields` does not mutate inputs.
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cypartagraphqlsubscriptionstools-4.1.5.tar.gz.
File metadata
- Download URL: cypartagraphqlsubscriptionstools-4.1.5.tar.gz
- Upload date:
- Size: 535.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8c9a3f676f72d5b2099c63e27c08040d088050a7486b3de19fe4ac0c5afe5d23
|
|
| MD5 |
ae77e21913a5d27c9fc60f793c15f03c
|
|
| BLAKE2b-256 |
9ed2e9c12d8cda7d5b93b7e335eb6ebf91e252aacf7037f56a915842a5164fad
|
File details
Details for the file cypartagraphqlsubscriptionstools-4.1.5-py3-none-any.whl.
File metadata
- Download URL: cypartagraphqlsubscriptionstools-4.1.5-py3-none-any.whl
- Upload date:
- Size: 25.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
86e7f5af770ee7100e9fa1ae9b69d48e4e94e44c3e65b4cb4c4d891978af2698
|
|
| MD5 |
6af287e9777c42047705b5e2e0a0fe97
|
|
| BLAKE2b-256 |
42a3faaeb2f33a0f6c415f97140e77f5afadccf7598f8b86966973707dc853b3
|