bazis-async-request 2.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ # Copyright 2026 EcoFuture Technology Services LLC and contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ try:
16
+ from importlib.metadata import PackageNotFoundError, version
17
+ __version__ = version('bazis-async-request')
18
+ except PackageNotFoundError:
19
+ __version__ = 'dev'
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '2.2.0'
32
+ __version_tuple__ = version_tuple = (2, 2, 0)
33
+
34
+ __commit_id__ = commit_id = None
@@ -0,0 +1,25 @@
1
+ # Copyright 2026 EcoFuture Technology Services LLC and contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from django.utils.translation import gettext_lazy as _
16
+
17
+ from bazis.core.utils.apps import BaseConfig
18
+
19
+
20
+ class AsyncRequestConfig(BaseConfig):
21
+ """Django AppConfig for the asynchronous background processing package (async_request)."""
22
+
23
+ name = "bazis.contrib.async_request"
24
+ verbose_name = _("AsyncRequest")
25
+ default = True
@@ -0,0 +1,104 @@
1
+ # Copyright 2026 EcoFuture Technology Services LLC and contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+
19
+ from django.conf import settings
20
+
21
+ from fastapi import Request
22
+
23
+ from starlette.datastructures import Headers
24
+ from starlette.responses import JSONResponse
25
+
26
+ from bazis.contrib.async_background.producer import enqueue_task_async
27
+ from bazis.contrib.async_background.routes import get_async_background_response
28
+ from bazis.contrib.async_background.utils import ChannelNameError, resolve_channel_name_async
29
+
30
+ from .utils import build_request_payload
31
+
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class AsyncRequestMiddleware:
37
+ def __init__(self, app):
38
+ self.app = app
39
+ self._no_bg_prefixes: tuple[str, ...] | None = None
40
+
41
+ def _build_no_bg_prefixes(self, app) -> tuple[str, ...]:
42
+ prefixes: list[str] = []
43
+ path = app.url_path_for(get_async_background_response.__name__, task_id="__dummy__")
44
+ if path:
45
+ prefix = str(path).replace("/__dummy__/", "/")
46
+ while "//" in prefix:
47
+ prefix = prefix.replace("//", "/")
48
+ prefixes.append(prefix)
49
+ return tuple(prefixes)
50
+
51
+ async def __call__(self, scope, receive, send) -> None:
52
+ if scope.get("type") != "http":
53
+ await self.app(scope, receive, send)
54
+ return
55
+
56
+ if self._no_bg_prefixes is None:
57
+ scope_app = scope.get("app")
58
+ print(scope_app.user_middleware)
59
+ self._no_bg_prefixes = self._build_no_bg_prefixes(scope_app or self.app)
60
+
61
+ path = scope.get("path", "")
62
+ if self._no_bg_prefixes and any(path.startswith(prefix) for prefix in self._no_bg_prefixes):
63
+ await self.app(scope, receive, send)
64
+ return
65
+
66
+ headers = Headers(scope=scope)
67
+ if (
68
+ headers.get("X-Async-Background-Internal", "").lower() == "true"
69
+ or "X-Async-Background" not in headers
70
+ ):
71
+ await self.app(scope, receive, send)
72
+ return
73
+
74
+ if not settings.KAFKA_ENABLED:
75
+ logger.warning(
76
+ "Incorrect Kafka settings, it is impossible to execute the request in the background."
77
+ )
78
+ await self.app(scope, receive, send)
79
+ return
80
+
81
+ request = Request(scope, receive)
82
+ await request.body()
83
+ try:
84
+ channel_name = await resolve_channel_name_async(request)
85
+ except ChannelNameError as err:
86
+ response = JSONResponse(status_code=401, content={'detail': str(err)})
87
+ await response(scope, receive, send)
88
+ return
89
+
90
+ payload = build_request_payload(request)
91
+ message = await enqueue_task_async(
92
+ topic_name=settings.KAFKA_TOPIC_ASYNC_REQUEST,
93
+ channel_name=channel_name,
94
+ payload=payload,
95
+ partition_marker=(
96
+ payload.body.get("data", {}).get("id") if isinstance(payload.body, dict) else None
97
+ ),
98
+ )
99
+
100
+ response = JSONResponse(
101
+ status_code=202,
102
+ content={"data": None, "meta": {"async_request_id": message.task_id}},
103
+ )
104
+ await response(scope, receive, send)
@@ -0,0 +1,36 @@
1
+ # Copyright 2026 EcoFuture Technology Services LLC and contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+
16
+ from pydantic import BaseModel, Field
17
+
18
+
19
+ class AsyncRequestPayload(BaseModel):
20
+ """Payload of a background HTTP request serialized for Kafka."""
21
+
22
+ path: str = Field(..., description="Request path")
23
+ query_string: str = Field(..., description="Query string")
24
+ headers: list[tuple[str | bytes, str | bytes]] = Field(
25
+ ..., description="List of HTTP headers"
26
+ ) # List of tuples (header_name, header_value)
27
+ request_client: str | tuple | None = Field(..., description="Client address of the request")
28
+ method: str = Field(..., description="HTTP method")
29
+ type: str = Field(..., description="Request type: http or websocket")
30
+ http_version: str = Field(..., description="HTTP version")
31
+ scheme: str = Field(..., description="Request scheme")
32
+ body: dict | list[dict] = Field(default_factory=dict, description="Request body")
33
+
34
+ class Config:
35
+ json_encoders = {bytes: lambda v: v.decode("utf-8")}
36
+ use_enum_values = True
@@ -0,0 +1,133 @@
1
+ # Copyright 2026 EcoFuture Technology Services LLC and contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import json
16
+ import logging
17
+ from urllib.parse import urlparse
18
+
19
+ from django.conf import settings
20
+
21
+ from bazis.contrib.async_background.broker import get_broker_for_consumer
22
+ from bazis.contrib.async_background.schemas import KafkaTask, TaskStatus
23
+ from bazis.contrib.async_background.utils import set_and_publish_status_async
24
+ from bazis.contrib.async_request.schemas import AsyncRequestPayload
25
+
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ _subscriber_kwargs: dict[str, object] = {
31
+ "auto_offset_reset": settings.KAFKA_AUTO_OFFSET_RESET,
32
+ "auto_commit": settings.KAFKA_ENABLE_AUTO_COMMIT,
33
+ "auto_commit_interval_ms": settings.KAFKA_AUTO_COMMIT_INTERVAL_MS,
34
+ }
35
+ if settings.KAFKA_GROUP_ID:
36
+ _subscriber_kwargs["group_id"] = settings.KAFKA_GROUP_ID
37
+
38
+
39
+ @get_broker_for_consumer().subscriber(settings.KAFKA_TOPIC_ASYNC_REQUEST, **_subscriber_kwargs)
40
+ async def consumer_async_requests(task: KafkaTask[AsyncRequestPayload]):
41
+ """Executes a background HTTP request from Kafka."""
42
+
43
+ await set_and_publish_status_async(
44
+ task_id=task.task_id,
45
+ channel_name=task.channel_name,
46
+ status=TaskStatus.PROCESSING,
47
+ )
48
+
49
+ try:
50
+ response = await execute_internal_request(task)
51
+ except Exception as err:
52
+ logger.exception("Failed to process task_id=%s", task.task_id)
53
+ await set_and_publish_status_async(
54
+ task_id=task.task_id,
55
+ channel_name=task.channel_name,
56
+ status=TaskStatus.FAILED,
57
+ response={"error": str(err)},
58
+ )
59
+ raise
60
+
61
+ await set_and_publish_status_async(
62
+ task_id=task.task_id,
63
+ channel_name=task.channel_name,
64
+ status=TaskStatus.COMPLETED,
65
+ response=response,
66
+ )
67
+
68
+ logger.info(
69
+ "Processed task_id=%s with status=%s.",
70
+ task.task_id,
71
+ response.get("status"),
72
+ )
73
+
74
+
75
+ async def execute_internal_request(task: KafkaTask[AsyncRequestPayload]) -> dict:
76
+ """Executes an internal HTTP request and returns the result."""
77
+ request = task.payload
78
+
79
+ url = urlparse(request.path)
80
+
81
+ headers = []
82
+ for key, value in request.headers:
83
+ key_bytes = key if isinstance(key, bytes) else str(key).encode("utf-8")
84
+ value_bytes = value if isinstance(value, bytes) else str(value).encode("utf-8")
85
+ headers.append((key_bytes, value_bytes))
86
+
87
+ scope = {
88
+ "type": request.type,
89
+ "http_version": request.http_version,
90
+ "method": request.method,
91
+ "scheme": request.scheme,
92
+ "path": url.path,
93
+ "raw_path": url.path.encode("utf-8"),
94
+ "query_string": request.query_string.encode(),
95
+ "headers": headers,
96
+ "client": request.request_client,
97
+ }
98
+
99
+ result = {
100
+ "task_id": task.task_id,
101
+ "endpoint": request.path,
102
+ "status": None,
103
+ "headers": [],
104
+ "response": None,
105
+ }
106
+
107
+ async def receive():
108
+ return {
109
+ "type": "http.request",
110
+ "body": json.dumps(request.body).encode("utf-8"),
111
+ }
112
+
113
+ async def send(message):
114
+ if message["type"] == "http.response.start":
115
+ result["status"] = message["status"]
116
+ decoded_headers = []
117
+ for key, value in message.get("headers", []):
118
+ key_str = key.decode("latin-1") if isinstance(key, (bytes, bytearray)) else str(key)
119
+ value_str = (
120
+ value.decode("latin-1") if isinstance(value, (bytes, bytearray)) else str(value)
121
+ )
122
+ decoded_headers.append([key_str, value_str])
123
+ result["headers"] = decoded_headers
124
+ elif message["type"] == "http.response.body":
125
+ body = message.get("body", b"")
126
+ try:
127
+ result["response"] = json.loads(body)
128
+ except Exception:
129
+ result["response"] = body.decode("utf-8", errors="replace")
130
+
131
+ from bazis.core.app import app
132
+ await app(scope, receive, send)
133
+ return result
@@ -0,0 +1,67 @@
1
+ # Copyright 2026 EcoFuture Technology Services LLC and contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import json
16
+ import logging
17
+
18
+ from fastapi import HTTPException, Request, status
19
+
20
+ from .schemas import AsyncRequestPayload
21
+
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def build_request_payload(request: Request) -> AsyncRequestPayload:
27
+ """Creates a payload for sending to Kafka."""
28
+ body_raw: bytes = request.scope.get("_cached_body") or getattr(request, "_body", b"")
29
+ try:
30
+ body: dict = json.loads(body_raw.decode("utf-8")) if body_raw else {}
31
+ except json.JSONDecodeError:
32
+ body = {}
33
+
34
+ headers: list[tuple[str, str]] = []
35
+ for k, v in request.scope.get("headers", []):
36
+ try:
37
+ k_val, v_val = k.decode(), v.decode()
38
+ if k_val.lower() not in ("x-async-background",):
39
+ headers.append((k_val, v_val))
40
+ except Exception as e:
41
+ logger.exception("Error decoding header: %s", e)
42
+
43
+ headers.append(("x-async-background-internal", "true"))
44
+
45
+ return AsyncRequestPayload(
46
+ path=request.url.path,
47
+ query_string=request.url.query,
48
+ headers=headers,
49
+ request_client=request.client,
50
+ method=request.method,
51
+ type=request.scope["type"],
52
+ http_version=request.scope["http_version"],
53
+ scheme=request.scope["scheme"],
54
+ body=body,
55
+ )
56
+
57
+
58
+ async def require_async(request: Request) -> None:
59
+ """Allow only async-request or internal async-request requests."""
60
+ if request.headers.get("X-Async-Background-Internal", "").lower() == "true":
61
+ return
62
+ if "X-Async-Background" in request.headers:
63
+ return
64
+ raise HTTPException(
65
+ status_code=status.HTTP_409_CONFLICT,
66
+ detail="This endpoint is available only via async request.",
67
+ )
@@ -0,0 +1,706 @@
1
+ Metadata-Version: 2.4
2
+ Name: bazis-async-request
3
+ Version: 2.2.0
4
+ Summary: Async Background Requests module for Bazis framework.
5
+ Author-email: Ilya Kharyn <ilya.tt07@gmail.com>
6
+ Maintainer-email: Ilya Kharyn <ilya.tt07@gmail.com>
7
+ Project-URL: Home, https://github.com/ecofuture-tech/bazis-async-request
8
+ Keywords: bazis,django,fastapi,async,background,kafka,framework
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Framework :: Django
17
+ Classifier: Framework :: FastAPI
18
+ Requires-Python: >=3.12
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: bazis-async-background
21
+ Provides-Extra: test
22
+ Requires-Dist: bazis-test-utils; extra == "test"
23
+ Provides-Extra: dev
24
+ Requires-Dist: ruff; extra == "dev"
25
+
26
+ # Bazis Async Request
27
+
28
+ [![PyPI version](https://img.shields.io/pypi/v/bazis-async-request.svg)](https://pypi.org/project/bazis-async-request/)
29
+ [![Python Versions](https://img.shields.io/pypi/pyversions/bazis-async-request.svg)](https://pypi.org/project/bazis-async-request/)
30
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
31
+
32
+ An extension package for Bazis that provides background processing for "heavy" HTTP requests on top of bazis-async-background.
33
+
34
+ ## Quick Start
35
+
36
+ ```bash
37
+ # Install the package
38
+ uv add bazis-async-request
39
+
40
+ # Configure environment variables / settings (.env uses BS_ prefix)
41
+ BS_INSTALLED_APPS='["bazis.contrib.async_request", "bazis.contrib.async_background", ...]'
42
+ BS_BAZIS_CONFIG_APPS='["bazis.contrib.async_request", "bazis.contrib.async_background", ...]'
43
+ BS_KAFKA_TASKS='["bazis.contrib.async_request.tasks"]'
44
+ BS_KAFKA_BOOTSTRAP_SERVERS=localhost:9093
45
+ BS_KAFKA_TOPIC_ASYNC_REQUEST=my_app_develop_async_request
46
+ BS_KAFKA_GROUP_ID=my_app_develop
47
+
48
+ # Run consumer in Kubernetes
49
+ python manage.py kafka_consumer_single
50
+
51
+ # Run multiple consumers locally
52
+ python manage.py kafka_consumer_multiple --consumers-count=5
53
+ ```
54
+
55
+ ## Table of Contents
56
+
57
+ - [Description](#description)
58
+ - [Requirements](#requirements)
59
+ - [Installation](#installation)
60
+ - [Architecture](#architecture)
61
+ - [Configuration](#configuration)
62
+ - [Environment Variables / Settings](#environment-variables--settings)
63
+ - [Route Registration](#route-registration)
64
+ - [Usage](#usage)
65
+ - [Project-Level Middleware](#project-level-middleware)
66
+ - [Running Consumers](#running-consumers)
67
+ - [Working with Frontend](#working-with-frontend)
68
+ - [Sending a Request](#sending-a-request)
69
+ - [Getting Result via WebSocket](#getting-result-via-websocket)
70
+ - [Getting Result via API](#getting-result-via-api)
71
+ - [Examples](#examples)
72
+ - [License](#license)
73
+ - [Links](#links)
74
+
75
+ ## Description
76
+
77
+ **Bazis Async Request** is an extension package for the Bazis framework that allows processing "heavy" requests in the background. The package includes:
78
+
79
+ - **AsyncRequestMiddleware** — project-level middleware for automatic background processing of any request
80
+ - **Kafka Producer** — sending tasks to Kafka queue
81
+ - **Kafka Consumer** — processing tasks from the queue
82
+ - **Redis storage** — storing task execution results
83
+ - **WebSocket notifications** — automatic user notification about task status
84
+ - **API endpoint** — retrieving results by task_id
85
+
86
+ **How it works**: When sending a request with the `X-Async-Request: true` header, the request is not executed immediately but is placed in the Kafka queue. The consumer retrieves the task from the queue, executes it, saves the result in Redis, and sends a notification to the user via WebSocket.
87
+
88
+ **This package requires the installation of `bazis`, `bazis-users`, `bazis-ws` packages and running Kafka and Redis servers.**
89
+
90
+ ## Requirements
91
+
92
+ - **Python**: 3.12+
93
+ - **bazis**: latest version
94
+ - **bazis-async-background**: latest version
95
+ - **bazis-ws**: latest version (for WebSocket notifications)
96
+ - **PostgreSQL**: 12+
97
+ - **Redis**: For storing results and caching
98
+ - **Kafka**: For task queue
99
+
100
+ ## Installation
101
+
102
+ ### Using uv (recommended)
103
+
104
+ ```bash
105
+ uv add bazis-async-request
106
+ ```
107
+
108
+ ### Using pip
109
+
110
+ ```bash
111
+ pip install bazis-async-request
112
+ ```
113
+
114
+ ## Running Tests
115
+
116
+ Run from the project root:
117
+
118
+ ```bash
119
+ docker compose -f sample/docker-compose.test.yml up --build --exit-code-from bazis-async-request-pytest --attach bazis-async-request-pytest --attach bazis-async-request-consumer-test
120
+ ```
121
+
122
+ This waits for the pytest container to finish and streams logs only from the Python containers, so test completion and output are easy to follow.
123
+
124
+ ## Architecture
125
+
126
+ ```
127
+ ┌─────────────┐
128
+ │ Client │
129
+ └──────┬──────┘
130
+ │ POST + X-Async-Request: true
131
+ ▼
132
+ ┌─────────────────────┐
133
+ │ API Endpoint │
134
+ │ (AsyncRequestMiddleware)│
135
+ └──────┬──────────────┘
136
+ │ 1. Return task_id (202)
137
+ │ 2. Send to Kafka
138
+ ▼
139
+ ┌─────────────────────┐
140
+ │ Kafka Topic │
141
+ │ (async_request) │
142
+ └──────┬──────────────┘
143
+ │
144
+ │ Consumer polls
145
+ ▼
146
+ ┌─────────────────────┐
147
+ │ Kafka Consumer │
148
+ │ (Background Worker)│
149
+ └──────┬──────────────┘
150
+ │ 3. Process request
151
+ │ 4. Save result to Redis
152
+ │ 5. Send WebSocket notification
153
+ ▼
154
+ ┌─────────────────────┐ ┌──────────────┐
155
+ │ Redis │◄────────┤ WebSocket │
156
+ │ (Results Store) │ │ │
157
+ └──────┬──────────────┘ └───────┬──────┘
158
+ │ │
159
+ │ 6. GET /async_request_ │ 6. Receive notification
160
+ │ response/{task_id}/ │ {status: "completed"}
161
+ ▼ ▼
162
+ ┌─────────────────────┐ ┌──────────────┐
163
+ │ API Endpoint │────────►│ Client │
164
+ │ (Get Result) │ │ │
165
+ └─────────────────────┘ └──────────────┘
166
+ ```
167
+
168
+ ## Configuration
169
+
170
+ ### Environment Variables / Settings
171
+
172
+ Add to your `.env` (use `BS_` prefix for Bazis settings) or `settings.py`:
173
+
174
+ ```bash
175
+ # Required settings
176
+ INSTALLED_APPS='["bazis.contrib.async_request", "bazis.contrib.async_background", ...]'
177
+ BAZIS_CONFIG_APPS='["bazis.contrib.async_request", "bazis.contrib.async_background", ...]'
178
+ KAFKA_TASKS='["bazis.contrib.async_request.tasks"]'
179
+
180
+ # Kafka settings
181
+ KAFKA_BOOTSTRAP_SERVERS=localhost:9093
182
+ KAFKA_TOPIC_ASYNC_REQUEST=my_app_develop_async_request
183
+ KAFKA_GROUP_ID=my_app_develop
184
+
185
+ # Optional settings
186
+ KAFKA_CONSUMER_LIFETIME_SEC=900 # Consumer lifetime (15 minutes)
187
+ KAFKA_CONSUMER_LIFETIME_JITTER_SEC=180 # Random deviation (3 minutes)
188
+ KAFKA_AUTO_OFFSET_RESET=latest
189
+ KAFKA_ENABLE_AUTO_COMMIT=true
190
+ KAFKA_AUTO_COMMIT_INTERVAL_MS=5000
191
+ KAFKA_LOG_LEVEL=INFO
192
+ ```
193
+
194
+ When placing these in a `.env` file, prefix them with `BS_`, for example:
195
+
196
+ ```bash
197
+ BS_INSTALLED_APPS='["bazis.contrib.async_request", "bazis.contrib.async_background", ...]'
198
+ BS_BAZIS_CONFIG_APPS='["bazis.contrib.async_request", "bazis.contrib.async_background", ...]'
199
+ BS_KAFKA_TASKS='["bazis.contrib.async_request.tasks"]'
200
+ ```
201
+
202
+ **Parameters**:
203
+
204
+ - `KAFKA_TASKS` — dotted module paths imported by the consumer to register tasks
205
+ - `KAFKA_BOOTSTRAP_SERVERS` — Kafka broker address
206
+ - `KAFKA_TOPIC_ASYNC_REQUEST` — topic for async tasks
207
+ - `KAFKA_GROUP_ID` — consumer group
208
+ - `KAFKA_CONSUMER_LIFETIME_SEC` — consumer working time before restart
209
+ - `KAFKA_CONSUMER_LIFETIME_JITTER_SEC` — random deviation to avoid simultaneous restart
210
+ - `KAFKA_AUTO_OFFSET_RESET` — Kafka auto offset reset policy
211
+ - `KAFKA_ENABLE_AUTO_COMMIT` — Kafka auto-commit toggle
212
+ - `KAFKA_AUTO_COMMIT_INTERVAL_MS` — auto-commit interval in ms
213
+ - `KAFKA_LOG_LEVEL` — log level for consumers
214
+
215
+ ### Route Registration
216
+
217
+ Add the route for getting results to your `router.py`:
218
+
219
+ ```python
220
+ from bazis.core.routing import BazisRouter
221
+
222
+ router = BazisRouter(prefix='/api/v1')
223
+
224
+ # Register background task results route
225
+ router.register('bazis.contrib.async_background.router')
226
+ ```
227
+
228
+ This adds the endpoint: `GET /api/v1/async_background_response/{task_id}/`
229
+
230
+ ## Usage
231
+
232
+ ### Project-Level Middleware
233
+
234
+ AsyncRequestMiddleware is registered automatically when `bazis.contrib.async_request` is loaded.
235
+ Any request can be moved to background using the `X-Async-Request: true` header.
236
+
237
+ **Location**: `bazis.contrib.async_request.middleware.AsyncRequestMiddleware`
238
+
239
+ ### Endpoint-Only Async Request (Dependency)
240
+
241
+ Use a dependency to mark specific endpoints as async-only. Such routes will return `409 Conflict`
242
+ unless the request includes `X-Async-Request` (or an internal background call with
243
+ `X-Async-Request-Internal: true`).
244
+
245
+ **Location**: `bazis.contrib.async_request.dependencies.require_async`
246
+
247
+ #### Attach to a Single Route
248
+
249
+ ```python
250
+ from fastapi import Depends
251
+ from bazis.contrib.async_request.dependencies import require_async
252
+
253
+ @router.post(
254
+ "/reports/generate/",
255
+ dependencies=[Depends(require_async)],
256
+ )
257
+ async def generate_report(...):
258
+ ...
259
+ ```
260
+
261
+ #### Attach via Function Signature
262
+
263
+ ```python
264
+ from fastapi import Depends
265
+ from bazis.contrib.async_request.dependencies import require_async
266
+
267
+ @router.post("/reports/generate/")
268
+ async def generate_report(
269
+ ...,
270
+ _async_request: None = Depends(require_async),
271
+ ):
272
+ ...
273
+ ```
274
+ ### Running Consumers
275
+
276
+ #### For Kubernetes (one consumer per pod)
277
+
278
+ ```bash
279
+ python manage.py kafka_consumer_single
280
+ ```
281
+
282
+ Runs one consumer that processes tasks from Kafka. Suitable for horizontal scaling in Kubernetes.
283
+
284
+ #### For Local Development (multiple consumers)
285
+
286
+ ```bash
287
+ python manage.py kafka_consumer_multiple --consumers-count=5
288
+ ```
289
+
290
+ Runs 5 consumers in separate processes. Suitable for local development or deployment without orchestration.
291
+
292
+ **Parameters**:
293
+
294
+ - `--consumers-count` — number of consumers to run (default: 1)
295
+
296
+ ## Working with Frontend
297
+
298
+ ### Sending a Request
299
+
300
+ Add the `X-Async-Request: true` header to your request:
301
+
302
+ ```bash
303
+ curl -X POST \
304
+ http://localhost/api/v1/orders/order/ \
305
+ -H "Authorization: Bearer YOUR_JWT_TOKEN" \
306
+ -H "Content-Type: application/vnd.api+json" \
307
+ -H "X-Async-Request: true" \
308
+ -d '{
309
+ "data": {
310
+ "type": "myapp.order",
311
+ "attributes": {
312
+ "description": "New Order",
313
+ "amount": 1000
314
+ }
315
+ }
316
+ }'
317
+ ```
318
+
319
+ **Response** (status 202 Accepted):
320
+
321
+ ```json
322
+ {
323
+ "data": null,
324
+ "meta": {
325
+ "async_request_id": "371564b0-29a5-457a-aabb-9c43661148a7"
326
+ }
327
+ }
328
+ ```
329
+
330
+ If the request has no `Authorization` header, pass a channel name directly:
331
+
332
+ ```bash
333
+ X-Async-Request: <channel_name>
334
+ ```
335
+
336
+ Save the `async_request_id` — this is the task identifier for retrieving the result.
337
+
338
+ ### Getting Result via WebSocket
339
+
340
+ After sending the task, connect to WebSocket (requires `bazis-ws` package) and wait for notifications:
341
+
342
+ ```javascript
343
+ // Connect to WebSocket (see bazis-ws documentation)
344
+ const ws = new WebSocket(`ws://api.example.com/ws?token=${jwtToken}`);
345
+
346
+ ws.onmessage = (event) => {
347
+ const data = JSON.parse(event.data);
348
+
349
+ if (data.type === 'data') {
350
+ const message = JSON.parse(data.data);
351
+
352
+ if (message.action === 'async_bg') {
353
+ console.log('Task ID:', message.task_id);
354
+ console.log('Status:', message.status);
355
+
356
+ if (message.status === 'completed') {
357
+ // Task completed, get result
358
+ fetchResult(message.task_id);
359
+ } else if (message.status === 'failed') {
360
+ // Task failed
361
+ console.error('Task failed');
362
+ }
363
+ }
364
+ }
365
+ };
366
+ ```
367
+
368
+ **WebSocket Notification Format**:
369
+
370
+ ```json
371
+ {
372
+ "type": "data",
373
+ "data": "{\"task_id\": \"371564b0-29a5-457a-aabb-9c43661148a7\", \"status\": \"completed\", \"action\": \"async_request\"}"
374
+ }
375
+ ```
376
+
377
+ **Task Statuses**:
378
+
379
+ - `completed` — task successfully completed
380
+ - `failed` — task failed with error
381
+
382
+ ### Getting Result via API
383
+
384
+ After receiving notification about task completion, request the result:
385
+
386
+ ```bash
387
+ GET /api/v1/async_background_response/{task_id}/
388
+ Authorization: Bearer YOUR_JWT_TOKEN
389
+ ```
390
+
391
+ **Example Request**:
392
+
393
+ ```bash
394
+ curl -X GET \
395
+ http://localhost/api/v1/async_background_response/371564b0-29a5-457a-aabb-9c43661148a7/ \
396
+ -H "Authorization: Bearer YOUR_JWT_TOKEN"
397
+ ```
398
+
399
+ **Success Response** (status 200):
400
+
401
+ ```json
402
+ {
403
+ "task_id": "371564b0-29a5-457a-aabb-9c43661148a7",
404
+ "status": 200,
405
+ "endpoint": "/api/v1/orders/order/e7cc4c8c-3ed1-4576-96ad-b3fd7c0b2a5a/",
406
+ "headers": [
407
+ ["content-length", "217"],
408
+ ["content-type", "application/vnd.api+json"]
409
+ ],
410
+ "response": {
411
+ "data": {
412
+ "type": "myapp.order",
413
+ "id": "e7cc4c8c-3ed1-4576-96ad-b3fd7c0b2a5a",
414
+ "attributes": {
415
+ "description": "New Order",
416
+ "amount": 1000,
417
+ "status": "draft"
418
+ }
419
+ }
420
+ }
421
+ }
422
+ ```
423
+
424
+ **Error Response** (status 403):
425
+
426
+ ```json
427
+ {
428
+ "task_id": "371564b0-29a5-457a-aabb-9c43661148a7",
429
+ "status": 403,
430
+ "endpoint": "/api/v1/orders/order/e7cc4c8c-3ed1-4576-96ad-b3fd7c0b2a5a/",
431
+ "headers": [
432
+ ["content-length", "4496"],
433
+ ["content-type", "application/json"]
434
+ ],
435
+ "response": {
436
+ "errors": [
437
+ {
438
+ "detail": "Permission denied: check access",
439
+ "status": 403
440
+ }
441
+ ]
442
+ }
443
+ }
444
+ ```
445
+
446
+ ## Examples
447
+
448
+ ### Complete Example with Frontend
449
+
450
+ **Backend (models.py)**:
451
+
452
+ ```python
453
+ from bazis.core.models_abstract import DtMixin, UuidMixin, JsonApiMixin
454
+ from django.db import models
455
+
456
+ class Report(DtMixin, UuidMixin, JsonApiMixin):
457
+ """Report whose generation takes time"""
458
+ title = models.CharField('Title', max_length=255)
459
+ date_from = models.DateField('Date From')
460
+ date_to = models.DateField('Date To')
461
+ status = models.CharField('Status', max_length=50, default='pending')
462
+ result_data = models.JSONField('Report Data', null=True, blank=True)
463
+
464
+ class Meta:
465
+ verbose_name = 'Report'
466
+ verbose_name_plural = 'Reports'
467
+ ```
468
+
469
+ **Frontend (JavaScript)**:
470
+
471
+ ```javascript
472
+ class AsyncReportClient {
473
+ constructor(apiUrl, wsUrl, token) {
474
+ this.apiUrl = apiUrl;
475
+ this.token = token;
476
+ this.ws = null;
477
+ this.pendingTasks = new Map();
478
+
479
+ // Connect to WebSocket
480
+ this.connectWebSocket(wsUrl);
481
+ }
482
+
483
+ connectWebSocket(wsUrl) {
484
+ this.ws = new WebSocket(`${wsUrl}?token=${this.token}`);
485
+
486
+ this.ws.onmessage = (event) => {
487
+ const data = JSON.parse(event.data);
488
+
489
+ if (data.type === 'data') {
490
+ const message = JSON.parse(data.data);
491
+
492
+ if (message.action === 'async_bg') {
493
+ this.handleTaskUpdate(message.task_id, message.status);
494
+ }
495
+ }
496
+ };
497
+ }
498
+
499
+ async createReport(title, dateFrom, dateTo) {
500
+ // Send request to create report
501
+ const response = await fetch(`${this.apiUrl}/reports/report/`, {
502
+ method: 'POST',
503
+ headers: {
504
+ 'Authorization': `Bearer ${this.token}`,
505
+ 'Content-Type': 'application/vnd.api+json',
506
+ 'X-Async-Request': 'true'
507
+ },
508
+ body: JSON.stringify({
509
+ data: {
510
+ type: 'myapp.report',
511
+ attributes: {
512
+ title: title,
513
+ date_from: dateFrom,
514
+ date_to: dateTo
515
+ }
516
+ }
517
+ })
518
+ });
519
+
520
+ const result = await response.json();
521
+ const taskId = result.meta.async_request_id;
522
+
523
+ // Save promise to wait for result
524
+ return new Promise((resolve, reject) => {
525
+ this.pendingTasks.set(taskId, { resolve, reject });
526
+ });
527
+ }
528
+
529
+ async handleTaskUpdate(taskId, status) {
530
+ if (!this.pendingTasks.has(taskId)) return;
531
+
532
+ const { resolve, reject } = this.pendingTasks.get(taskId);
533
+
534
+ if (status === 'completed') {
535
+ // Get result
536
+ const result = await this.getResult(taskId);
537
+ this.pendingTasks.delete(taskId);
538
+ resolve(result);
539
+ } else if (status === 'failed') {
540
+ const error = await this.getResult(taskId);
541
+ this.pendingTasks.delete(taskId);
542
+ reject(error);
543
+ }
544
+ }
545
+
546
+ async getResult(taskId) {
547
+ const response = await fetch(
548
+ `${this.apiUrl}/async_background_response/${taskId}/`,
549
+ {
550
+ headers: {
551
+ 'Authorization': `Bearer ${this.token}`
552
+ }
553
+ }
554
+ );
555
+ return await response.json();
556
+ }
557
+ }
558
+
559
+ // Usage
560
+ const client = new AsyncReportClient(
561
+ 'http://api.example.com/api/v1',
562
+ 'ws://api.example.com/ws',
563
+ jwtToken
564
+ );
565
+
566
+ // Create report
567
+ client.createReport('Monthly Report', '2024-01-01', '2024-01-31')
568
+ .then(result => {
569
+ console.log('Report created:', result);
570
+ // Display result to user
571
+ })
572
+ .catch(error => {
573
+ console.error('Report generation failed:', error);
574
+ });
575
+ ```
576
+
577
+ ### Example for Custom Endpoint
578
+
579
+ **Backend**:
580
+
581
+ ```python
582
+ from fastapi import Request, Depends
583
+ from django.contrib.auth import get_user_model
584
+ from bazis.core.routing import BazisRouter
585
+ from bazis.contrib.users.service import get_user_from_token
586
+ import time
587
+
588
+ User = get_user_model()
589
+ router = BazisRouter(tags=["Analytics"])
590
+
591
+ @router.post('/generate-analytics/', response_model=dict)
592
+ async def generate_analytics(
593
+ report_type: str,
594
+ date_from: str,
595
+ date_to: str,
596
+ request: Request,
597
+ user: User = Depends(get_user_from_token)
598
+ ):
599
+ """
600
+ Generate analytics report (long operation)
601
+ """
602
+ # Simulate long processing
603
+ time.sleep(10)
604
+
605
+ # Generate report
606
+ analytics_data = {
607
+ 'report_type': report_type,
608
+ 'period': f'{date_from} - {date_to}',
609
+ 'total_orders': 1250,
610
+ 'revenue': 125000.50,
611
+ 'average_order': 100.00
612
+ }
613
+
614
+ return {
615
+ 'status': 'success',
616
+ 'data': analytics_data
617
+ }
618
+ ```
619
+
620
+ **Frontend**:
621
+
622
+ ```bash
623
+ # Synchronous request (will wait 10 seconds)
624
+ POST /api/v1/generate-analytics/
625
+ Content-Type: application/json
626
+
627
+ {
628
+ "report_type": "sales",
629
+ "date_from": "2024-01-01",
630
+ "date_to": "2024-01-31"
631
+ }
632
+
633
+ # Asynchronous request (returns task_id immediately)
634
+ POST /api/v1/generate-analytics/
635
+ Content-Type: application/json
636
+ X-Async-Request: true
637
+
638
+ {
639
+ "report_type": "sales",
640
+ "date_from": "2024-01-01",
641
+ "date_to": "2024-01-31"
642
+ }
643
+ ```
644
+
645
+ ### Error Handling Example
646
+
647
+ ```python
648
+ from bazis.core.routes_abstract.jsonapi import JsonapiRouteBase
649
+ from django.apps import apps
650
+
651
+ class OrderRouteSet(JsonapiRouteBase):
652
+ model = apps.get_model("myapp", "Order")
653
+
654
+ def hook_before_update(self, item):
655
+ """Check before update"""
656
+ if item.status == 'draft' and not self.inject.user.is_staff:
657
+ from fastapi import HTTPException
658
+ raise HTTPException(
659
+ status_code=403,
660
+ detail="Permission denied: check access"
661
+ )
662
+ super().hook_before_update(item)
663
+ ```
664
+
665
+ **Result with async processing**:
666
+
667
+ ```json
668
+ {
669
+ "task_id": "371564b0-29a5-457a-aabb-9c43661148a7",
670
+ "status": 403,
671
+ "response": {
672
+ "errors": [
673
+ {
674
+ "detail": "Permission denied: check access",
675
+ "status": 403
676
+ }
677
+ ]
678
+ }
679
+ }
680
+ ```
681
+
682
+ ## License
683
+
684
+ Apache License 2.0
685
+
686
+ See [LICENSE](LICENSE) file for details.
687
+
688
+ ## Links
689
+
690
+ - [Bazis Documentation](https://github.com/ecofuture-tech/bazis) — main repository
691
+ - [Bazis WS](https://github.com/ecofuture-tech/bazis-ws) — WebSocket package
692
+ - [Bazis Async Background Repository](https://github.com/ecofuture-tech/bazis-async-background) — core background framework
693
+ - [Bazis Async Request Repository](https://github.com/ecofuture-tech/bazis-async-request) — package repository
694
+ - [Issue Tracker](https://github.com/ecofuture-tech/bazis-async-request/issues) — report bugs or request features
695
+ - [Apache Kafka](https://kafka.apache.org/) — Kafka documentation
696
+
697
+ ## Support
698
+
699
+ If you have questions or issues:
700
+ - Review the [Bazis documentation](https://github.com/ecofuture-tech/bazis)
701
+ - Search [existing issues](https://github.com/ecofuture-tech/bazis-async-request/issues)
702
+ - Create a [new issue](https://github.com/ecofuture-tech/bazis-async-request/issues/new) with detailed information
703
+
704
+ ---
705
+
706
+ Made with ❤️ by the Bazis team
@@ -0,0 +1,11 @@
1
+ bazis/contrib/async_request/__init__.py,sha256=tDikAsHzuK1HXEh-EQ8aJzt0uqn1AWzIlR--C-AvhqA,787
2
+ bazis/contrib/async_request/_version.py,sha256=6OGz4a0gjMGlckPyPCNiJDWyFDO-tWO8O_ZNx4ajT2Y,704
3
+ bazis/contrib/async_request/apps.py,sha256=le2m51a2rSh1Yj6m75xWrWWBQqDdjMw3XvCSYx0pajU,949
4
+ bazis/contrib/async_request/middleware.py,sha256=LWKGTLEBPOeeM_ZbARg-_ylmbrDU4ELBemBxsFSsHs8,3731
5
+ bazis/contrib/async_request/schemas.py,sha256=rE1C3fkLZfsZU1DwIJ2o5wPcrpEGGqzxIew1Yg9VdKY,1588
6
+ bazis/contrib/async_request/tasks.py,sha256=04gW2yWJ6T23HQWL1TRgFvRbgcLqg0tf_gxFtK0WjWY,4552
7
+ bazis/contrib/async_request/utils.py,sha256=ql6FDhAN2sjWtqsyWi2Ql_XLWU9R7R4s3SqkZK-OzNw,2319
8
+ bazis_async_request-2.2.0.dist-info/METADATA,sha256=MhK1-s6LTJNoWgnwtoYsIQHbrS9dutjIRfB34OZlO4M,20538
9
+ bazis_async_request-2.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
10
+ bazis_async_request-2.2.0.dist-info/top_level.txt,sha256=WgdrPZTZBMG8i_EqxA3vU5qI4ETQ_RsqKqSqsfIApHY,6
11
+ bazis_async_request-2.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ bazis