dynamicforms-fastapi-viewsets 0.10.0__tar.gz → 0.11.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 (69) hide show
  1. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/PKG-INFO +2 -1
  2. dynamicforms_fastapi_viewsets-0.11.0/fastapi_viewsets/__init__.py +1 -0
  3. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/action_configuration.py +26 -0
  4. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/decorators/celery_viewset/__init__.py +5 -5
  5. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/decorators/celery_viewset/client.py +48 -16
  6. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +64 -22
  7. dynamicforms_fastapi_viewsets-0.11.0/fastapi_viewsets/decorators/celery_viewset/server.py +718 -0
  8. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/decorators/lifecycle_runner.py +13 -1
  9. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/exceptions.py +129 -6
  10. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/middleware/__init__.py +127 -10
  11. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/middleware/idempotency.py +16 -28
  12. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/pyproject.toml +4 -0
  13. dynamicforms_fastapi_viewsets-0.10.0/fastapi_viewsets/__init__.py +0 -1
  14. dynamicforms_fastapi_viewsets-0.10.0/fastapi_viewsets/decorators/celery_viewset/server.py +0 -271
  15. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/.gitignore +0 -0
  16. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/LICENSE +0 -0
  17. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/README.md +0 -0
  18. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/backends/__init__.py +0 -0
  19. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/backends/django_orm.py +0 -0
  20. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/collection_viewset.py +0 -0
  21. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/conf.py +0 -0
  22. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/context/__init__.py +0 -0
  23. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/context/auth/__init__.py +0 -0
  24. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/context/auth/django.py +0 -0
  25. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/context/auth/jwt.py +0 -0
  26. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/context/auth/static.py +0 -0
  27. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/cursor.py +0 -0
  28. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/decorators/__init__.py +0 -0
  29. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/decorators/build_schema.py +0 -0
  30. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
  31. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/decorators/conftest.py +0 -0
  32. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
  33. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/decorators/route_viewset.py +0 -0
  34. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/endpoint_docs.py +0 -0
  35. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/__init__.py +0 -0
  36. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/authorizers.py +0 -0
  37. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/changes.py +0 -0
  38. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/conftest.py +0 -0
  39. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/delivery.py +0 -0
  40. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/event.py +0 -0
  41. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/lifecycle.py +0 -0
  42. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/redis.py +0 -0
  43. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/sources.py +0 -0
  44. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/store.py +0 -0
  45. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/topics.py +0 -0
  46. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/viewset.py +0 -0
  47. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/filters/__init__.py +0 -0
  48. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/filters/base.py +0 -0
  49. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/filters/declaration.py +0 -0
  50. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/filters/operators.py +0 -0
  51. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/filters/registry.py +0 -0
  52. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/list_query.py +0 -0
  53. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/list_shapes.py +0 -0
  54. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
  55. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
  56. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
  57. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/mixins.py +0 -0
  58. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/mux_ws/__init__.py +0 -0
  59. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/mux_ws/asgi.py +0 -0
  60. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/mux_ws/protocol.py +0 -0
  61. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/mux_ws/registry.py +0 -0
  62. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/mux_ws/server.py +0 -0
  63. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/mux_ws/transports.py +0 -0
  64. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/py.typed +0 -0
  65. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/response_classes.py +0 -0
  66. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/routing.py +0 -0
  67. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/save_state/__init__.py +0 -0
  68. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/save_state/save_state.py +0 -0
  69. {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/save_state/serialize_state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dynamicforms-fastapi-viewsets
3
- Version: 0.10.0
3
+ Version: 0.11.0
4
4
  Summary: Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart.
5
5
  Project-URL: Homepage, https://github.com/dynamicforms/fastapi-viewsets
6
6
  Project-URL: Repository, https://github.com/dynamicforms/fastapi-viewsets
@@ -33,6 +33,7 @@ Requires-Dist: asgiref; extra == 'dev'
33
33
  Requires-Dist: celery; extra == 'dev'
34
34
  Requires-Dist: coverage; extra == 'dev'
35
35
  Requires-Dist: django; extra == 'dev'
36
+ Requires-Dist: gevent; extra == 'dev'
36
37
  Requires-Dist: httpx2; extra == 'dev'
37
38
  Requires-Dist: muxws[starlette,websockets]>=0.4.1; extra == 'dev'
38
39
  Requires-Dist: pyjwt; extra == 'dev'
@@ -0,0 +1 @@
1
+ __version__ = "0.11.0"
@@ -1,3 +1,4 @@
1
+ from dataclasses import dataclass
1
2
  from typing import Any
2
3
 
3
4
  from .conf import settings
@@ -47,6 +48,31 @@ def action_configuration(config: dict[Any, Any]):
47
48
  return decorator
48
49
 
49
50
 
51
+ @dataclass(frozen=True)
52
+ class TimeLimits:
53
+ """
54
+ The time limits, in seconds, of an execution of a deferred action, configured under the
55
+ `TimeLimits` key:
56
+
57
+ @action_configuration({TimeLimits: TimeLimits(soft=30, hard=60)})
58
+ class ReportViewSet(...): ...
59
+
60
+ `soft` interrupts the execution from inside: `Middleware.execute()` sees `ActionInterruptedError`
61
+ from `call_next()`, `finally` blocks and `save_state()` run, and the caller receives the error.
62
+ `hard` ends the execution's process: the execution unwinds as `SystemExit` only as far as it
63
+ gets before the process is killed, the caller receives `ActionInterruptedError` with reason
64
+ `time_limit`, and `Middleware.on_transition()` receives `interrupted`. None leaves a limit to
65
+ the executor's own configuration.
66
+
67
+ The executor enforces them (`celery_viewset` sends them with each call); an in-process action
68
+ has no time limit. A value in `settings.default_action_configuration` applies to every action,
69
+ and `ByAction` varies it per action.
70
+ """
71
+
72
+ soft: float | None = None
73
+ hard: float | None = None
74
+
75
+
50
76
  def _candidate_method_names(action_name: str) -> list[str]:
51
77
  names = [action_name]
52
78
  perform_name = _PERFORM_METHOD_BY_ACTION.get(action_name)
@@ -49,14 +49,14 @@ def celery_viewset(
49
49
  - In a Celery worker (detected via sys.argv or set_is_celery_worker): uses celery_viewset_server
50
50
  - In a FastAPI app: uses celery_viewset_client
51
51
 
52
- For explicit control, use celery_viewset_client or celery_viewset_server directly.
52
+ For explicit control, use celery_viewset_client or celery_viewset_server directly. Both modes
53
+ require redis_client.
53
54
  """
55
+ if redis_client is None:
56
+ raise ValueError("redis_client is required for celery_viewset")
54
57
  if _detect_is_celery_worker():
55
58
  return celery_viewset_server(celery_app, task_prefix, lifecycle=lifecycle, redis_client=redis_client)
56
- else:
57
- if redis_client is None:
58
- raise ValueError("redis_client is required for celery_viewset in client (FastAPI) mode")
59
- return celery_viewset_client(celery_app, task_prefix, redis_client=redis_client, result_timeout=result_timeout)
59
+ return celery_viewset_client(celery_app, task_prefix, redis_client=redis_client, result_timeout=result_timeout)
60
60
 
61
61
 
62
62
  def check_result_readers() -> frozenset[str]:
@@ -8,10 +8,12 @@ from typing import TYPE_CHECKING, TypeVar
8
8
 
9
9
  from pydantic import BaseModel
10
10
 
11
+ from ...action_configuration import resolve_action_configuration, TimeLimits
11
12
  from ...context import Context, SerializableObject, serialize_context, to_jsonable
12
13
  from ...exceptions import ActionDispatchError, ResultTimeoutError
14
+ from ...middleware import ActionTransition, run_transition_listeners
13
15
  from ..build_schema import build_schema
14
- from ..lifecycle_runner import current_call, DEFERRED_ACTIONS_ATTRIBUTE
16
+ from ..lifecycle_runner import current_call, DEFERRED_ACTIONS_ATTRIBUTE, effective_middlewares
15
17
  from ..primary_key_model_helper import PARTIAL_UPDATE_ACTIONS
16
18
  from . import result_reader
17
19
  from .payload import CONTEXT_KWARG, PARENT_KWARG, REQUEST_KWARG, request_to_jsonable
@@ -96,14 +98,30 @@ async def _serialize_value(value, partial: bool = False):
96
98
  return to_jsonable(value)
97
99
 
98
100
 
101
+ def _time_limit_options(context: Context) -> dict:
102
+ """The `send_task` time limit options of the call's `TimeLimits` configuration."""
103
+ limits = context.configuration_for(TimeLimits)
104
+ if limits is None:
105
+ return {}
106
+ options = {}
107
+ if limits.soft is not None:
108
+ options["soft_time_limit"] = limits.soft
109
+ if limits.hard is not None:
110
+ options["time_limit"] = limits.hard
111
+ return options
112
+
113
+
99
114
  def _patch_method(
100
115
  cls: type, original_endpoint, task_name: str, celery_app, queue_key: str, result_timeout: float | None
101
116
  ):
102
117
  """Replace the method on cls with an async version that sends a Celery task and awaits the result.
103
118
 
104
119
  The task carries the call's context, request and parent execution (see `payload`) from
105
- `current_call()`; a `context` the caller passes replaces the call's. A send that fails raises
106
- `ActionDispatchError`. The action's name joins the class's `DEFERRED_ACTIONS_ATTRIBUTE`."""
120
+ `current_call()`; a `context` the caller passes replaces the call's. The action's `TimeLimits`
121
+ configuration is sent as the task's time limits. Arguments that cannot be serialized raise
122
+ `ActionDispatchError` (reason `serialization`), a send that fails raises it with reason `send`.
123
+ Once the task is sent, `Middleware.on_transition()` of the action's effective middlewares
124
+ receives `dispatched`. The action's name joins the class's `DEFERRED_ACTIONS_ATTRIBUTE`."""
107
125
  method_name = original_endpoint.__name__
108
126
  declares_request = "request" in inspect.signature(original_endpoint).parameters
109
127
 
@@ -122,10 +140,26 @@ def _patch_method(
122
140
  if declares_request:
123
141
  kwargs.pop("request", None)
124
142
  parent = call.parent_of_dispatch() if call is not None else None
125
- serializable_args = [await _serialize_value(v) for v in args]
126
- serializable_kwargs = {
127
- k: await _serialize_value(v, method_name in PARTIAL_UPDATE_ACTIONS) for k, v in kwargs.items()
128
- }
143
+ action_configuration = resolve_action_configuration(cls, method_name)
144
+ bound_context = Context(context.raw(), action_configuration=action_configuration, action_name=method_name)
145
+ try:
146
+ serializable_args = [await _serialize_value(v) for v in args]
147
+ serializable_kwargs = {
148
+ k: await _serialize_value(v, method_name in PARTIAL_UPDATE_ACTIONS) for k, v in kwargs.items()
149
+ }
150
+ task_kwargs = {
151
+ **serializable_kwargs,
152
+ CONTEXT_KWARG: await serialize_context(context.raw()),
153
+ REQUEST_KWARG: request_to_jsonable(call.request if call is not None else None),
154
+ PARENT_KWARG: parent.to_jsonable() if parent is not None else None,
155
+ "_correlation_id": correlation_id,
156
+ "_result_queue_key": get_process_queue_key(queue_key),
157
+ }
158
+ except Exception as error:
159
+ logger.error(
160
+ "Celery task arguments not serializable: %s (correlation_id=%s)", task_name, correlation_id
161
+ )
162
+ raise ActionDispatchError(task_name, "serialization") from error
129
163
  logger.info("Celery task scheduling: %s (correlation_id=%s)", task_name, correlation_id)
130
164
  try:
131
165
  # send_task() blocks while it connects and publishes to the broker.
@@ -133,23 +167,21 @@ def _patch_method(
133
167
  celery_app.send_task,
134
168
  task_name,
135
169
  args=serializable_args,
136
- kwargs={
137
- **serializable_kwargs,
138
- CONTEXT_KWARG: await serialize_context(context.raw()),
139
- REQUEST_KWARG: request_to_jsonable(call.request if call is not None else None),
140
- PARENT_KWARG: parent.to_jsonable() if parent is not None else None,
141
- "_correlation_id": correlation_id,
142
- "_result_queue_key": get_process_queue_key(queue_key),
143
- },
170
+ kwargs=task_kwargs,
144
171
  # send_task() defaults ignore_result to False regardless of what the task was
145
172
  # registered with (celery_viewset_server sets ignore_result=True) - Celery's
146
173
  # per-request value overrides the task's own, so it must be repeated here or the
147
174
  # worker still tries to JSON-encode the raw return value into its result backend.
148
175
  ignore_result=True,
176
+ **_time_limit_options(bound_context),
149
177
  )
150
178
  except Exception as error:
151
179
  logger.error("Celery task not sent: %s (correlation_id=%s)", task_name, correlation_id)
152
- raise ActionDispatchError(task_name) from error
180
+ raise ActionDispatchError(task_name, "send") from error
181
+ await run_transition_listeners(
182
+ effective_middlewares(action_configuration),
183
+ ActionTransition("dispatched", cls, method_name, bound_context, parent, None),
184
+ )
153
185
  try:
154
186
  return await asyncio.wait_for(future, result_timeout)
155
187
  except asyncio.TimeoutError:
@@ -7,9 +7,10 @@ import socket
7
7
  from typing import TYPE_CHECKING
8
8
 
9
9
  from fastapi import HTTPException
10
+ from fastapi.exceptions import RequestValidationError
10
11
 
11
12
  from ...context import from_jsonable
12
- from ...exceptions import ResultReaderStoppedError
13
+ from ...exceptions import _error_from_jsonable, _error_to_jsonable, ActionFailedError, ResultReaderStoppedError
13
14
 
14
15
  if TYPE_CHECKING:
15
16
  import redis
@@ -88,27 +89,12 @@ async def _read_results(redis_client: "redis.Redis", queue_key: str, poll_interv
88
89
 
89
90
  data = json.loads(item)
90
91
  correlation_id = data.get("correlation_id")
91
- result = data.get("result")
92
- error = data.get("error")
93
92
 
94
93
  pending = _pending_futures.pop(correlation_id, None)
95
94
  if pending is None:
96
95
  logger.warning("No pending future for correlation_id=%s", correlation_id)
97
96
  continue
98
- future = pending[0]
99
-
100
- if not future.done():
101
- if error is not None:
102
- http_status_code = data.get("http_status_code")
103
- http_detail = data.get("http_detail")
104
- if http_status_code is not None:
105
- future.set_exception(HTTPException(status_code=http_status_code, detail=http_detail))
106
- else:
107
- future.set_exception(Exception(error))
108
- else:
109
- # Undoes _to_jsonable's SerializableObject tagging (see server.py) at any depth -
110
- # an untagged value passes through unchanged.
111
- future.set_result(from_jsonable(result))
97
+ _resolve(pending[0], data)
112
98
 
113
99
  except asyncio.CancelledError:
114
100
  break
@@ -117,22 +103,78 @@ async def _read_results(redis_client: "redis.Redis", queue_key: str, poll_interv
117
103
  await asyncio.sleep(poll_interval)
118
104
 
119
105
 
106
+ def _resolve(future: asyncio.Future, data: dict) -> None:
107
+ """Resolves `future` with the result or the error of one payload."""
108
+ if future.done():
109
+ return
110
+ task = data.get("task")
111
+ try:
112
+ if data.get("error") is not None:
113
+ future.set_exception(_payload_error(data))
114
+ else:
115
+ # Undoes _to_jsonable's SerializableObject tagging (see server.py) at any depth - an
116
+ # untagged value passes through unchanged.
117
+ future.set_result(from_jsonable(data.get("result")))
118
+ except Exception as error:
119
+ logger.exception("Result of %s (correlation_id=%s) cannot be read", task, data.get("correlation_id"))
120
+ failure = ActionFailedError(task, "result")
121
+ failure.__cause__ = error
122
+ future.set_exception(failure)
123
+
124
+
125
+ def _payload_error(data: dict) -> Exception:
126
+ """The exception a payload's `error` carries. An `error` that is not encoded fields is an
127
+ `HTTPException` when the payload has `http_status_code`, else an `ActionFailedError`."""
128
+ error = data["error"]
129
+ if isinstance(error, dict):
130
+ return _error_from_jsonable(error)
131
+ if data.get("http_status_code") is not None:
132
+ return HTTPException(status_code=data["http_status_code"], detail=data.get("http_detail"))
133
+ return ActionFailedError(data.get("task"))
134
+
135
+
136
+ def error_payload(correlation_id: str, task: str | None, error: BaseException) -> dict:
137
+ """
138
+ The payload that fails one call with `error`: an `HTTPException` (a `DfViewSetError` with its
139
+ class), any other error as `ActionFailedError`. `http_status_code` and `http_detail` repeat the
140
+ status and detail for a reader that does not decode `error`.
141
+ """
142
+ if not isinstance(error, (HTTPException, RequestValidationError)):
143
+ error = ActionFailedError(task)
144
+ encoded = _error_to_jsonable(error)
145
+ return {
146
+ "correlation_id": correlation_id,
147
+ "task": task,
148
+ "error": encoded,
149
+ "http_status_code": encoded["status_code"],
150
+ "http_detail": encoded["detail"],
151
+ }
152
+
153
+
120
154
  def push_payload(redis_client: "redis.Redis", queue_key: str, data: dict) -> None:
121
155
  """Push one result payload to queue_key and renew the list's expiry to RESULT_QUEUE_TTL."""
122
156
  redis_client.rpush(queue_key, json.dumps(data))
123
157
  redis_client.expire(queue_key, RESULT_QUEUE_TTL)
124
158
 
125
159
 
126
- def push_result(redis_client: "redis.Redis", queue_key: str, correlation_id: str, result=None, error=None) -> None:
160
+ def push_result(
161
+ redis_client: "redis.Redis",
162
+ queue_key: str,
163
+ correlation_id: str,
164
+ result=None,
165
+ error: BaseException | None = None,
166
+ task: str | None = None,
167
+ ) -> None:
127
168
  """
128
169
  Push a result or an error for one call. queue_key is the `_result_queue_key` the client sent with
129
- the task.
170
+ the task. An `error` that is an `HTTPException` reaches the caller as it is (a `DfViewSetError`
171
+ of a class `fastapi_viewsets.exceptions` defines keeps its class); any other error reaches it
172
+ as `ActionFailedError`. `task` names the call's task in that error.
130
173
  """
131
- data = {"correlation_id": correlation_id}
132
174
  if error is not None:
133
- data["error"] = str(error)
175
+ data = error_payload(correlation_id, task, error)
134
176
  else:
135
- data["result"] = result
177
+ data = {"correlation_id": correlation_id, "task": task, "result": result}
136
178
  push_payload(redis_client, queue_key, data)
137
179
 
138
180