bazis-async-request 2.3.0__tar.gz → 2.4.0__tar.gz

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.
Files changed (71) hide show
  1. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/.github/workflows/release.yml +5 -2
  2. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/.github/workflows/tests.yml +1 -1
  3. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/CLAUDE.md +1 -1
  4. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/PKG-INFO +14 -4
  5. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/README.md +12 -2
  6. bazis_async_request-2.4.0/bazis/contrib/async_request/AGENTS.md +75 -0
  7. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/bazis/contrib/async_request/_version.py +3 -3
  8. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/bazis/contrib/async_request/apps.py +5 -0
  9. bazis_async_request-2.4.0/bazis/contrib/async_request/bazis_manifest.toml +52 -0
  10. bazis_async_request-2.4.0/bazis/contrib/async_request/checks.py +70 -0
  11. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/bazis/contrib/async_request/middleware.py +11 -3
  12. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/bazis/contrib/async_request/utils.py +7 -2
  13. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/bazis_async_request.egg-info/PKG-INFO +14 -4
  14. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/bazis_async_request.egg-info/SOURCES.txt +5 -0
  15. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/bazis_async_request.egg-info/requires.txt +1 -1
  16. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/bazis_async_request.egg-info/scm_file_list.json +5 -0
  17. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/bazis_async_request.egg-info/scm_version.json +2 -2
  18. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/docs/releases/2.3.0.md +1 -1
  19. bazis_async_request-2.4.0/docs/releases/2.4.0.md +14 -0
  20. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/pyproject.toml +1 -1
  21. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/test.env +1 -1
  22. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/tests/test_internal.py +47 -0
  23. bazis_async_request-2.4.0/tests/test_manifest.py +36 -0
  24. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/.dockerignore +0 -0
  25. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/.github/workflows/build-publish.yml +0 -0
  26. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/.github/workflows/update-licenses.yml +0 -0
  27. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/.gitignore +0 -0
  28. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/bazis/contrib/async_request/__init__.py +0 -0
  29. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/bazis/contrib/async_request/schemas.py +0 -0
  30. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/bazis/contrib/async_request/tasks.py +0 -0
  31. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/bazis_async_request.egg-info/dependency_links.txt +0 -0
  32. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/bazis_async_request.egg-info/top_level.txt +0 -0
  33. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/pytest.ini +0 -0
  34. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/Dockerfile +0 -0
  35. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/docker-compose.kafka.yml +0 -0
  36. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/docker-compose.test.yml +0 -0
  37. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/fast_start/__init__.py +0 -0
  38. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/fast_start/admin.py +0 -0
  39. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/fast_start/migrations/0001_initial.py +0 -0
  40. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/fast_start/migrations/0002_remove_order_fast_start__autogen_8f29b0_gin_and_more.py +0 -0
  41. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/fast_start/migrations/__init__.py +0 -0
  42. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/fast_start/models.py +0 -0
  43. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/fast_start/router.py +0 -0
  44. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/fast_start/routes.py +0 -0
  45. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/fast_start/schemas.py +0 -0
  46. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/manage.py +0 -0
  47. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/project.env +0 -0
  48. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/pyproject.toml +0 -0
  49. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/sample/__init__.py +0 -0
  50. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/sample/main.py +0 -0
  51. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/sample/router.py +0 -0
  52. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/sample/settings.py +0 -0
  53. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/sample/urls.py +0 -0
  54. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/sample/wsgi.py +0 -0
  55. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/users/__init__.py +0 -0
  56. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/users/admin.py +0 -0
  57. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/users/migrations/0001_initial.py +0 -0
  58. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/users/migrations/__init__.py +0 -0
  59. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/users/models.py +0 -0
  60. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/sample/uv.lock +0 -0
  61. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/setup.cfg +0 -0
  62. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/tests/__init__.py +0 -0
  63. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/tests/conftest.py +0 -0
  64. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/tests/test_decorator.py +0 -0
  65. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/tests/test_multiple_order_requests.py +0 -0
  66. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/tests/test_order_access.py +0 -0
  67. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/tests/test_order_patch.py +0 -0
  68. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/tests/test_order_view.py +0 -0
  69. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/tests/test_shop_calc_and_params.py +0 -0
  70. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/tests/test_shop_force_background_methods.py +0 -0
  71. {bazis_async_request-2.3.0 → bazis_async_request-2.4.0}/tests/utils.py +0 -0
@@ -42,9 +42,12 @@ jobs:
42
42
  echo "::error::Tag v$VERSION already exists"
43
43
  exit 1
44
44
  fi
45
- LAST_TAG=$(git tag --list 'v*' --sort=-v:refname | head -n 1)
45
+ # versionsort.suffix: a pre-release (v2.4.0-rc1) sorts before its release (v2.4.0)
46
+ LAST_TAG=$(git -c versionsort.suffix=- tag --list 'v*' --sort=-v:refname | head -n 1)
46
47
  echo "Last release: ${LAST_TAG:-none}, new release: v$VERSION"
47
- if [ -n "$LAST_TAG" ] && [ "$(printf '%s\n%s\n' "${LAST_TAG#v}" "$VERSION" | sort -V | tail -n 1)" != "$VERSION" ]; then
48
+ # sort -V orders "~" before anything, as versionsort.suffix does with "-"
49
+ NEWEST=$(printf '%s\n%s\n' "${LAST_TAG#v}" "$VERSION" | sed 's/-/~/' | sort -V | tail -n 1)
50
+ if [ -n "$LAST_TAG" ] && [ "$NEWEST" != "${VERSION/-/\~}" ]; then
48
51
  echo "::error::Version $VERSION is not newer than $LAST_TAG"
49
52
  exit 1
50
53
  fi
@@ -20,7 +20,7 @@ jobs:
20
20
  steps:
21
21
  - uses: actions/checkout@v4
22
22
  - uses: astral-sh/setup-uv@v6
23
- - run: uvx ruff check bazis tests
23
+ - run: uvx ruff check bazis tests sample
24
24
 
25
25
  tests:
26
26
  name: tests (python ${{ matrix.python }}, ${{ matrix.resolution }})
@@ -38,7 +38,7 @@ to the database name): migrate it first (`python manage.py migrate` and
38
38
  The tests marked `run_with_consumer` are skipped without Kafka settings. A local Kafka
39
39
  without Docker: download the Kafka binaries and start a single KRaft node.
40
40
 
41
- Lint: `ruff check bazis tests`. CI also runs `python manage.py makemigrations --check
41
+ Lint: `ruff check bazis tests sample`. CI also runs `python manage.py makemigrations --check
42
42
  --dry-run` in `sample`: commit the migrations of model changes, including the sample apps.
43
43
 
44
44
  ## Releasing
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bazis-async-request
3
- Version: 2.3.0
3
+ Version: 2.4.0
4
4
  Summary: Async Background Requests module for Bazis framework.
5
5
  Author-email: Ilya Kharyn <ilya.tt07@gmail.com>
6
6
  Maintainer-email: Ilya Kharyn <ilya.tt07@gmail.com>
@@ -17,7 +17,7 @@ Classifier: Framework :: Django
17
17
  Classifier: Framework :: FastAPI
18
18
  Requires-Python: >=3.12
19
19
  Description-Content-Type: text/markdown
20
- Requires-Dist: bazis>=2.3.1
20
+ Requires-Dist: bazis>=2.4.0
21
21
  Requires-Dist: bazis-async-background>=2.3.0
22
22
  Provides-Extra: test
23
23
  Requires-Dist: bazis-test-utils>=2.3.0; extra == "test"
@@ -236,7 +236,15 @@ This adds the endpoint: `GET /api/v1/async_background_response/{task_id}/`
236
236
 
237
237
  ### Project-Level Middleware
238
238
 
239
- AsyncRequestMiddleware is registered automatically when `bazis.contrib.async_request` is loaded.
239
+ Add the middleware to the application (it is not added automatically):
240
+
241
+ ```python
242
+ from bazis.contrib.async_request.middleware import AsyncRequestMiddleware
243
+ from bazis.core.app import app
244
+
245
+ app.add_middleware(AsyncRequestMiddleware)
246
+ ```
247
+
240
248
  Any request can be moved to background using the `X-Async-Background: true` header.
241
249
 
242
250
  **Location**: `bazis.contrib.async_request.middleware.AsyncRequestMiddleware`
@@ -298,7 +306,9 @@ Runs 5 consumers in separate processes. Suitable for local development or deploy
298
306
 
299
307
  **Parameters**:
300
308
 
301
- - `--consumers-count` — number of consumers to run (default: 1)
309
+ - `--consumers-count` — number of consumers to run (default: 15)
310
+ - `--restart-delay-sec` — delay before restarting a consumer that exited (default: 1.0)
311
+ - `--max-restarts` — restarts of a failed consumer before it is given up (default: unlimited)
302
312
 
303
313
  ## Working with Frontend
304
314
 
@@ -206,7 +206,15 @@ This adds the endpoint: `GET /api/v1/async_background_response/{task_id}/`
206
206
 
207
207
  ### Project-Level Middleware
208
208
 
209
- AsyncRequestMiddleware is registered automatically when `bazis.contrib.async_request` is loaded.
209
+ Add the middleware to the application (it is not added automatically):
210
+
211
+ ```python
212
+ from bazis.contrib.async_request.middleware import AsyncRequestMiddleware
213
+ from bazis.core.app import app
214
+
215
+ app.add_middleware(AsyncRequestMiddleware)
216
+ ```
217
+
210
218
  Any request can be moved to background using the `X-Async-Background: true` header.
211
219
 
212
220
  **Location**: `bazis.contrib.async_request.middleware.AsyncRequestMiddleware`
@@ -268,7 +276,9 @@ Runs 5 consumers in separate processes. Suitable for local development or deploy
268
276
 
269
277
  **Parameters**:
270
278
 
271
- - `--consumers-count` — number of consumers to run (default: 1)
279
+ - `--consumers-count` — number of consumers to run (default: 15)
280
+ - `--restart-delay-sec` — delay before restarting a consumer that exited (default: 1.0)
281
+ - `--max-restarts` — restarts of a failed consumer before it is given up (default: unlimited)
272
282
 
273
283
  ## Working with Frontend
274
284
 
@@ -0,0 +1,75 @@
1
+ # bazis-async-request — guide for AI agents
2
+
3
+ Runs any HTTP request of a Bazis API in the background: a request with the header
4
+ `X-Async-Background` is queued in Kafka (bazis-async-background) and answered 202; a
5
+ consumer replays it through the ASGI application and stores the response for
6
+ `GET /async_background_response/{task_id}/`. Use it for slow endpoints and bulk changes.
7
+
8
+ ## Setup
9
+
10
+ ```bash
11
+ BS_INSTALLED_APPS='[..., "bazis.contrib.async_request", "bazis.contrib.async_background"]'
12
+ BS_KAFKA_TASKS='["bazis.contrib.async_request.tasks"]' # the consumer of the requests
13
+ BS_KAFKA_BOOTSTRAP_SERVERS=kafka:9092
14
+ BS_KAFKA_TOPIC_ASYNC_BG=myproject_async_request
15
+ BS_KAFKA_GROUP_ID=myproject
16
+ ```
17
+
18
+ ```python
19
+ # the main module of the application (as sample/sample/main.py)
20
+ from bazis.contrib.async_request.middleware import AsyncRequestMiddleware
21
+ from bazis.core.app import app
22
+
23
+ app.add_middleware(AsyncRequestMiddleware) # not added automatically
24
+
25
+ # the root router
26
+ router.register('bazis.contrib.async_background.router') # async_request.E001
27
+ ```
28
+
29
+ - Services and consumers as in bazis-async-background: Kafka, Redis, and
30
+ `python manage.py kafka_consumer_single` (or `kafka_consumer_multiple`) with the same
31
+ settings and database as the API.
32
+ - Without Kafka settings the middleware executes these requests synchronously and only
33
+ logs a warning (`async_request.W001`).
34
+
35
+ ## Client
36
+
37
+ - Send the request as usual plus `X-Async-Background: true` (the header's presence is
38
+ enough) and `Authorization: Bearer <token>`: a session JWT, or an anonymous token of
39
+ bazis-ws (16–128 characters `A-Z a-z 0-9 _ -`). Without a valid token: 401. The request
40
+ is replayed with the same header: routes that read the user (bazis-users, permit, author)
41
+ answer 401 to an anonymous token, so use it only for routes that do not.
42
+ - Answer: 202 `{"data": null, "meta": {"async_request_id": <id>, "async_background_id": <id>}}`
43
+ (the same task id). Status updates arrive on the WebSocket channel of the token.
44
+ - Result: `GET .../async_background_response/{task_id}/` with the same token returns
45
+ `{"task_id", "endpoint", "status", "headers", "response"}` of the replayed request
46
+ (an HTTP error is a `completed` task with its status); `{"status": "not ready"}` before.
47
+
48
+ ## Endpoints only for background requests
49
+
50
+ ```python
51
+ from fastapi import Depends
52
+ from bazis.contrib.async_request.utils import require_async
53
+
54
+ @router.post('/reports/generate/', dependencies=[Depends(require_async)])
55
+ async def generate_report(...): ...
56
+
57
+ class ShopRouteSet(JsonapiRouteBase):
58
+ @inject_make(CrudApiAction.UPDATE) # bazis.core.routes_abstract.initial
59
+ class InjectRequireAsync:
60
+ async_request: None = Depends(require_async) # the annotation is required
61
+ ```
62
+
63
+ A direct request to such an endpoint is 409.
64
+
65
+ ## Rules
66
+
67
+ - Recognize the requests of the consumer only with `is_internal_request(scope)`
68
+ (`SCOPE_INTERNAL_KEY` in the ASGI scope, which clients cannot set). Never trust a
69
+ header: `X-Async-Background-Internal` is ignored.
70
+ - Only JSON bodies (an object or a list of objects) are queued: another body (multipart,
71
+ form, binary) is replayed as `{}`.
72
+ - The queued request keeps all headers, `Authorization` included, in the Kafka topic; it
73
+ runs with the client's token, so the token must still be valid when the consumer runs it.
74
+ - Requests with the same `data.id` in the body go to the same Kafka partition.
75
+ - WebSocket requests and the results route are never queued.
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.3.0'
22
- __version_tuple__ = version_tuple = (2, 3, 0)
21
+ __version__ = version = '2.4.0'
22
+ __version_tuple__ = version_tuple = (2, 4, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g7f4029675'
24
+ __commit_id__ = commit_id = 'g5ed041f2b'
@@ -23,3 +23,8 @@ class AsyncRequestConfig(BaseConfig):
23
23
  name = "bazis.contrib.async_request"
24
24
  verbose_name = _("AsyncRequest")
25
25
  default = True
26
+
27
+ def ready(self):
28
+ super().ready()
29
+
30
+ from . import checks # noqa: F401 registers the system checks
@@ -0,0 +1,52 @@
1
+ # What the package is for and how to use it, for tools and AI agents (see AGENTS.md).
2
+ # `import` paths and `check` ids are verified by tests/test_manifest.py.
3
+
4
+ [package]
5
+ name = "bazis-async-request"
6
+ summary = "run any HTTP request of the API in the background through Kafka (X-Async-Background)"
7
+ solves = [
8
+ "move slow requests or bulk changes to the background without new endpoints",
9
+ "restrict an endpoint to requests executed in the background",
10
+ ]
11
+ requires = ["bazis", "bazis-async-background"]
12
+ pairs_well = ["bazis-users", "bazis-permit"]
13
+
14
+ [[extension_points]]
15
+ name = "AsyncRequestMiddleware"
16
+ import = "bazis.contrib.async_request.middleware.AsyncRequestMiddleware"
17
+ use_when = "add to the application: app.add_middleware(AsyncRequestMiddleware)"
18
+
19
+ [[extension_points]]
20
+ name = "require_async"
21
+ import = "bazis.contrib.async_request.utils.require_async"
22
+ use_when = "a dependency that allows an endpoint only for background requests (409 otherwise)"
23
+
24
+ [[extension_points]]
25
+ name = "is_internal_request"
26
+ import = "bazis.contrib.async_request.utils.is_internal_request"
27
+ use_when = "whether a request (ASGI scope) is executed by the consumer"
28
+
29
+ [[extension_points]]
30
+ name = "consumer_async_requests"
31
+ import = "bazis.contrib.async_request.tasks.consumer_async_requests"
32
+ use_when = "the consumer of the requests: list bazis.contrib.async_request.tasks in KAFKA_TASKS"
33
+
34
+ [[pitfalls]]
35
+ text = "without Kafka settings the requests with X-Async-Background are executed synchronously"
36
+ check = "async_request.W001"
37
+
38
+ [[pitfalls]]
39
+ text = "without the route of bazis.contrib.async_background.router the middleware fails on every HTTP request"
40
+ check = "async_request.E001"
41
+
42
+ [[pitfalls]]
43
+ text = "the middleware is not added automatically: app.add_middleware(AsyncRequestMiddleware)"
44
+
45
+ [[pitfalls]]
46
+ text = "a header must never mark a request as internal: use is_internal_request(scope)"
47
+
48
+ [[pitfalls]]
49
+ text = "only JSON bodies are queued: multipart, form and binary bodies are replayed as {}"
50
+
51
+ [[pitfalls]]
52
+ text = "a dependency in an inject_make class without a type annotation is ignored"
@@ -0,0 +1,70 @@
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
+ Django system checks of bazis-async-request (see `manage.py bazis_doctor`).
17
+ """
18
+
19
+ from django.conf import settings
20
+ from django.core.checks import Error, Warning, register
21
+
22
+
23
+ @register()
24
+ def check_kafka_enabled(app_configs, **kwargs):
25
+ """
26
+ Without Kafka the middleware executes the requests sent with `X-Async-Background`
27
+ synchronously, and only logs a warning.
28
+ """
29
+ # the settings of bazis-async-background are missing if BS_BAZIS_APPS does not list it
30
+ if not getattr(settings, 'KAFKA_ENABLED', False):
31
+ return [
32
+ Warning(
33
+ 'Kafka is not configured: the requests sent with X-Async-Background are '
34
+ 'executed synchronously.',
35
+ hint=(
36
+ 'Set BS_KAFKA_BOOTSTRAP_SERVERS and BS_KAFKA_TOPIC_ASYNC_BG (and list '
37
+ 'bazis.contrib.async_background in BS_BAZIS_APPS if it is set).'
38
+ ),
39
+ id='async_request.W001',
40
+ )
41
+ ]
42
+ return []
43
+
44
+
45
+ @register()
46
+ def check_response_route(app_configs, **kwargs):
47
+ """
48
+ The middleware looks up the route of the results to let its requests through: without
49
+ it every HTTP request through the middleware fails. Runs when the application is
50
+ loaded (`manage.py bazis_doctor`).
51
+ """
52
+ from bazis.core.introspect import loaded_app
53
+
54
+ if (app := loaded_app()) is None:
55
+ return []
56
+
57
+ from starlette.routing import NoMatchFound
58
+
59
+ try:
60
+ app.url_path_for('get_async_background_response', task_id='__dummy__')
61
+ except NoMatchFound:
62
+ return [
63
+ Error(
64
+ 'The route of the background results is not registered: '
65
+ 'AsyncRequestMiddleware fails on every HTTP request.',
66
+ hint="Register it: router.register('bazis.contrib.async_background.router').",
67
+ id='async_request.E001',
68
+ )
69
+ ]
70
+ return []
@@ -33,6 +33,16 @@ from .utils import build_request_payload, is_internal_request
33
33
  logger = logging.getLogger(__name__)
34
34
 
35
35
 
36
+ def partition_marker(body) -> str | None:
37
+ """
38
+ The id of the JSON:API resource of the body: the requests of one resource are kept in
39
+ one Kafka partition, in order.
40
+ """
41
+ data = body.get("data") if isinstance(body, dict) else None
42
+ item_id = data.get("id") if isinstance(data, dict) else None
43
+ return str(item_id) if item_id is not None else None
44
+
45
+
36
46
  class AsyncRequestMiddleware:
37
47
  def __init__(self, app):
38
48
  self.app = app
@@ -88,9 +98,7 @@ class AsyncRequestMiddleware:
88
98
  topic_name=settings.KAFKA_TOPIC_ASYNC_BG,
89
99
  channel_name=channel_name,
90
100
  payload=payload,
91
- partition_marker=(
92
- payload.body.get("data", {}).get("id") if isinstance(payload.body, dict) else None
93
- ),
101
+ partition_marker=partition_marker(payload.body),
94
102
  )
95
103
 
96
104
  response = JSONResponse(
@@ -37,8 +37,13 @@ def build_request_payload(request: Request) -> AsyncRequestPayload:
37
37
  """Creates a payload for sending to Kafka."""
38
38
  body_raw: bytes = request.scope.get("_cached_body") or getattr(request, "_body", b"")
39
39
  try:
40
- body: dict = json.loads(body_raw.decode("utf-8")) if body_raw else {}
41
- except json.JSONDecodeError:
40
+ body = json.loads(body_raw.decode("utf-8")) if body_raw else {}
41
+ except ValueError:
42
+ # not JSON (multipart, binary): the body is not replayed
43
+ body = {}
44
+ if not isinstance(body, dict | list) or (
45
+ isinstance(body, list) and not all(isinstance(it, dict) for it in body)
46
+ ):
42
47
  body = {}
43
48
 
44
49
  headers: list[tuple[str, str]] = []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bazis-async-request
3
- Version: 2.3.0
3
+ Version: 2.4.0
4
4
  Summary: Async Background Requests module for Bazis framework.
5
5
  Author-email: Ilya Kharyn <ilya.tt07@gmail.com>
6
6
  Maintainer-email: Ilya Kharyn <ilya.tt07@gmail.com>
@@ -17,7 +17,7 @@ Classifier: Framework :: Django
17
17
  Classifier: Framework :: FastAPI
18
18
  Requires-Python: >=3.12
19
19
  Description-Content-Type: text/markdown
20
- Requires-Dist: bazis>=2.3.1
20
+ Requires-Dist: bazis>=2.4.0
21
21
  Requires-Dist: bazis-async-background>=2.3.0
22
22
  Provides-Extra: test
23
23
  Requires-Dist: bazis-test-utils>=2.3.0; extra == "test"
@@ -236,7 +236,15 @@ This adds the endpoint: `GET /api/v1/async_background_response/{task_id}/`
236
236
 
237
237
  ### Project-Level Middleware
238
238
 
239
- AsyncRequestMiddleware is registered automatically when `bazis.contrib.async_request` is loaded.
239
+ Add the middleware to the application (it is not added automatically):
240
+
241
+ ```python
242
+ from bazis.contrib.async_request.middleware import AsyncRequestMiddleware
243
+ from bazis.core.app import app
244
+
245
+ app.add_middleware(AsyncRequestMiddleware)
246
+ ```
247
+
240
248
  Any request can be moved to background using the `X-Async-Background: true` header.
241
249
 
242
250
  **Location**: `bazis.contrib.async_request.middleware.AsyncRequestMiddleware`
@@ -298,7 +306,9 @@ Runs 5 consumers in separate processes. Suitable for local development or deploy
298
306
 
299
307
  **Parameters**:
300
308
 
301
- - `--consumers-count` — number of consumers to run (default: 1)
309
+ - `--consumers-count` — number of consumers to run (default: 15)
310
+ - `--restart-delay-sec` — delay before restarting a consumer that exited (default: 1.0)
311
+ - `--max-restarts` — restarts of a failed consumer before it is given up (default: unlimited)
302
312
 
303
313
  ## Working with Frontend
304
314
 
@@ -8,9 +8,12 @@ pytest.ini
8
8
  .github/workflows/release.yml
9
9
  .github/workflows/tests.yml
10
10
  .github/workflows/update-licenses.yml
11
+ bazis/contrib/async_request/AGENTS.md
11
12
  bazis/contrib/async_request/__init__.py
12
13
  bazis/contrib/async_request/_version.py
13
14
  bazis/contrib/async_request/apps.py
15
+ bazis/contrib/async_request/bazis_manifest.toml
16
+ bazis/contrib/async_request/checks.py
14
17
  bazis/contrib/async_request/middleware.py
15
18
  bazis/contrib/async_request/schemas.py
16
19
  bazis/contrib/async_request/tasks.py
@@ -23,6 +26,7 @@ bazis_async_request.egg-info/scm_file_list.json
23
26
  bazis_async_request.egg-info/scm_version.json
24
27
  bazis_async_request.egg-info/top_level.txt
25
28
  docs/releases/2.3.0.md
29
+ docs/releases/2.4.0.md
26
30
  sample/Dockerfile
27
31
  sample/docker-compose.kafka.yml
28
32
  sample/docker-compose.test.yml
@@ -55,6 +59,7 @@ tests/__init__.py
55
59
  tests/conftest.py
56
60
  tests/test_decorator.py
57
61
  tests/test_internal.py
62
+ tests/test_manifest.py
58
63
  tests/test_multiple_order_requests.py
59
64
  tests/test_order_access.py
60
65
  tests/test_order_patch.py
@@ -1,4 +1,4 @@
1
- bazis>=2.3.1
1
+ bazis>=2.4.0
2
2
  bazis-async-background>=2.3.0
3
3
 
4
4
  [dev]
@@ -8,13 +8,17 @@
8
8
  ".gitignore",
9
9
  "CLAUDE.md",
10
10
  "README.md",
11
+ "bazis/contrib/async_request/AGENTS.md",
11
12
  "bazis/contrib/async_request/__init__.py",
12
13
  "bazis/contrib/async_request/apps.py",
14
+ "bazis/contrib/async_request/bazis_manifest.toml",
15
+ "bazis/contrib/async_request/checks.py",
13
16
  "bazis/contrib/async_request/middleware.py",
14
17
  "bazis/contrib/async_request/schemas.py",
15
18
  "bazis/contrib/async_request/tasks.py",
16
19
  "bazis/contrib/async_request/utils.py",
17
20
  "docs/releases/2.3.0.md",
21
+ "docs/releases/2.4.0.md",
18
22
  "pyproject.toml",
19
23
  "pytest.ini",
20
24
  "sample/Dockerfile",
@@ -49,6 +53,7 @@
49
53
  "tests/conftest.py",
50
54
  "tests/test_decorator.py",
51
55
  "tests/test_internal.py",
56
+ "tests/test_manifest.py",
52
57
  "tests/test_multiple_order_requests.py",
53
58
  "tests/test_order_access.py",
54
59
  "tests/test_order_patch.py",
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "2.3.0",
2
+ "tag": "2.4.0",
3
3
  "distance": 0,
4
- "node": "g7f4029675c3d5a89dc6151d1962037bd0313b25f",
4
+ "node": "g5ed041f2b5971a61028577e5aab70968628fa1fa",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-10-01"
@@ -20,7 +20,7 @@ Requires Bazis 2.3.1 and bazis-async-background 2.3.
20
20
  - **Consumers did not start with current FastStream** (`auto_commit` was removed): the
21
21
  subscriber uses `subscriber_kwargs()` of bazis-async-background.
22
22
  - The replayed request kept the `Content-Length` of the original request with a re-encoded
23
- body and lacked the ASGI version and lifespan `state`; `receive` no longer returns the
23
+ body and lacked the ASGI version and the `state` key; `receive` no longer returns the
24
24
  request again in a loop (streaming responses spun the CPU).
25
25
  - The path of the replayed request was decoded twice (`%2541` became `A`) and the
26
26
  `root_path` was lost: the payload now stores `path`, `raw_path` and `root_path` of the
@@ -0,0 +1,14 @@
1
+ # bazis-async-request 2.4.0
2
+
3
+ Requires Bazis 2.4.
4
+
5
+ ## Tools and AI agents
6
+
7
+ - The package ships `AGENTS.md` (setup, the client protocol, `require_async` and the
8
+ security rules) and `bazis_manifest.toml` for tools and AI agents (see `docs/agents.md`
9
+ of Bazis).
10
+ - New system checks (`manage.py bazis_doctor`):
11
+ - `async_request.W001`: Kafka is not configured, so the requests sent with
12
+ `X-Async-Background` are executed synchronously;
13
+ - `async_request.E001`: the route of the results (`bazis.contrib.async_background.router`)
14
+ is not registered, so `AsyncRequestMiddleware` fails on every HTTP request.
@@ -33,7 +33,7 @@ classifiers = [
33
33
  "Framework :: FastAPI",
34
34
  ]
35
35
  dependencies = [
36
- "bazis>=2.3.1",
36
+ "bazis>=2.4.0",
37
37
  "bazis-async-background>=2.3.0",
38
38
  ]
39
39
 
@@ -15,7 +15,7 @@ BS_KAFKA_CONSUMER_LIFETIME_JITTER_SEC=300
15
15
  PYTHONPATH=/app
16
16
 
17
17
  BS_DEBUG=true
18
- BS_SECRET_KEY=1232434535465476587689780999
18
+ BS_SECRET_KEY=local-secret-key-that-is-long-enough-0123456789
19
19
 
20
20
  BS_HOST_URL=http://localhost:9000
21
21
 
@@ -117,3 +117,50 @@ def test_payload_keeps_the_path_as_received():
117
117
  assert payload.path == "/prefix/api/v1/file/%41 b/"
118
118
  assert payload.raw_path == "/prefix/api/v1/file/%2541%20b/"
119
119
  assert payload.root_path == "/prefix"
120
+
121
+
122
+ @pytest.mark.parametrize('body', [b'\xff\xfe binary', b'"a string"', b'[1, 2]'])
123
+ def test_payload_of_a_body_that_is_not_a_resource(body):
124
+ """
125
+ A binary body failed with UnicodeDecodeError and a JSON body that is not an object (or
126
+ a list of objects) failed the validation of the payload: 500.
127
+ """
128
+ from starlette.requests import Request
129
+
130
+ from bazis.contrib.async_request.utils import build_request_payload
131
+
132
+ request = Request(
133
+ {
134
+ 'type': 'http',
135
+ 'method': 'POST',
136
+ 'scheme': 'http',
137
+ 'http_version': '1.1',
138
+ 'path': '/api/v1/files/',
139
+ 'raw_path': b'/api/v1/files/',
140
+ 'query_string': b'',
141
+ 'headers': [],
142
+ 'client': ('127.0.0.1', 1),
143
+ 'server': ('testserver', 80),
144
+ }
145
+ )
146
+ request._body = body
147
+ assert build_request_payload(request).body == {}
148
+
149
+
150
+ @pytest.mark.parametrize(
151
+ 'body, marker',
152
+ [
153
+ ({'data': {'id': 'a1', 'type': 'x.y'}}, 'a1'),
154
+ ({'data': {'id': 7}}, '7'),
155
+ ({'data': [{'id': 'a1'}]}, None),
156
+ ({'data': None}, None),
157
+ ([{'data': {'id': 'a1'}}], None),
158
+ ],
159
+ )
160
+ def test_partition_marker(body, marker):
161
+ """
162
+ The relationships bodies (`data` is a list or null) failed with AttributeError: 500.
163
+ """
164
+ from bazis.contrib.async_request.middleware import partition_marker
165
+
166
+ assert partition_marker(body) == marker
@@ -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
+ from fastapi import FastAPI
16
+
17
+ from bazis.contrib.async_request.checks import check_kafka_enabled, check_response_route
18
+ from bazis.core.introspect import validate_manifest
19
+
20
+
21
+ def test_manifest_is_valid():
22
+ assert validate_manifest('bazis.contrib.async_request') == []
23
+
24
+
25
+ def test_kafka_enabled_check(settings):
26
+ settings.KAFKA_ENABLED = False
27
+ assert [it.id for it in check_kafka_enabled(None)] == ['async_request.W001']
28
+ settings.KAFKA_ENABLED = True
29
+ assert check_kafka_enabled(None) == []
30
+
31
+
32
+ def test_response_route_check(sample_app, monkeypatch):
33
+ # the sample project registers bazis.contrib.async_background.router
34
+ assert check_response_route(None) == []
35
+ monkeypatch.setattr('bazis.core.introspect.loaded_app', lambda: FastAPI())
36
+ assert [it.id for it in check_response_route(None)] == ['async_request.E001']