django-cfg 1.5.14__py3-none-any.whl → 1.5.29__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.

Potentially problematic release.


This version of django-cfg might be problematic. Click here for more details.

Files changed (118) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/business/accounts/serializers/profile.py +42 -0
  3. django_cfg/apps/business/support/serializers.py +3 -2
  4. django_cfg/apps/integrations/centrifugo/__init__.py +2 -0
  5. django_cfg/apps/integrations/centrifugo/apps.py +2 -1
  6. django_cfg/apps/integrations/centrifugo/codegen/generators/typescript_thin/templates/rpc-client.ts.j2 +151 -12
  7. django_cfg/apps/integrations/centrifugo/management/commands/generate_centrifugo_clients.py +2 -2
  8. django_cfg/apps/integrations/centrifugo/services/__init__.py +6 -0
  9. django_cfg/apps/integrations/centrifugo/services/client/__init__.py +6 -1
  10. django_cfg/apps/integrations/centrifugo/services/client/client.py +1 -1
  11. django_cfg/apps/integrations/centrifugo/services/client/direct_client.py +282 -0
  12. django_cfg/apps/integrations/centrifugo/services/logging.py +47 -0
  13. django_cfg/apps/integrations/centrifugo/services/publisher.py +371 -0
  14. django_cfg/apps/integrations/centrifugo/services/token_generator.py +122 -0
  15. django_cfg/apps/integrations/centrifugo/urls.py +8 -0
  16. django_cfg/apps/integrations/centrifugo/views/__init__.py +2 -0
  17. django_cfg/apps/integrations/centrifugo/views/admin_api.py +29 -32
  18. django_cfg/apps/integrations/centrifugo/views/testing_api.py +31 -116
  19. django_cfg/apps/integrations/centrifugo/views/token_api.py +101 -0
  20. django_cfg/apps/integrations/centrifugo/views/wrapper.py +259 -0
  21. django_cfg/apps/integrations/grpc/auth/api_key_auth.py +11 -10
  22. django_cfg/apps/integrations/grpc/management/commands/compile_proto.py +105 -0
  23. django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +56 -1
  24. django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +315 -26
  25. django_cfg/apps/integrations/grpc/management/proto/__init__.py +3 -0
  26. django_cfg/apps/integrations/grpc/management/proto/compiler.py +194 -0
  27. django_cfg/apps/integrations/grpc/managers/grpc_request_log.py +84 -0
  28. django_cfg/apps/integrations/grpc/managers/grpc_server_status.py +126 -3
  29. django_cfg/apps/integrations/grpc/models/grpc_api_key.py +7 -1
  30. django_cfg/apps/integrations/grpc/models/grpc_server_status.py +22 -3
  31. django_cfg/apps/integrations/grpc/services/__init__.py +102 -17
  32. django_cfg/apps/integrations/grpc/services/centrifugo/__init__.py +29 -0
  33. django_cfg/apps/integrations/grpc/services/centrifugo/bridge.py +469 -0
  34. django_cfg/apps/integrations/grpc/services/centrifugo/config.py +167 -0
  35. django_cfg/apps/integrations/grpc/services/centrifugo/demo.py +626 -0
  36. django_cfg/apps/integrations/grpc/services/centrifugo/test_publish.py +229 -0
  37. django_cfg/apps/integrations/grpc/services/centrifugo/transformers.py +89 -0
  38. django_cfg/apps/integrations/grpc/services/client/__init__.py +26 -0
  39. django_cfg/apps/integrations/grpc/services/commands/IMPLEMENTATION.md +456 -0
  40. django_cfg/apps/integrations/grpc/services/commands/README.md +252 -0
  41. django_cfg/apps/integrations/grpc/services/commands/__init__.py +93 -0
  42. django_cfg/apps/integrations/grpc/services/commands/base.py +243 -0
  43. django_cfg/apps/integrations/grpc/services/commands/examples/__init__.py +22 -0
  44. django_cfg/apps/integrations/grpc/services/commands/examples/base_client.py +228 -0
  45. django_cfg/apps/integrations/grpc/services/commands/examples/client.py +272 -0
  46. django_cfg/apps/integrations/grpc/services/commands/examples/config.py +177 -0
  47. django_cfg/apps/integrations/grpc/services/commands/examples/start.py +125 -0
  48. django_cfg/apps/integrations/grpc/services/commands/examples/stop.py +101 -0
  49. django_cfg/apps/integrations/grpc/services/commands/registry.py +170 -0
  50. django_cfg/apps/integrations/grpc/services/discovery/__init__.py +39 -0
  51. django_cfg/apps/integrations/grpc/services/{discovery.py → discovery/discovery.py} +67 -54
  52. django_cfg/apps/integrations/grpc/services/{service_registry.py → discovery/registry.py} +215 -5
  53. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/__init__.py +3 -1
  54. django_cfg/apps/integrations/grpc/services/interceptors/centrifugo.py +541 -0
  55. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/metrics.py +3 -3
  56. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/request_logger.py +10 -13
  57. django_cfg/apps/integrations/grpc/services/management/__init__.py +37 -0
  58. django_cfg/apps/integrations/grpc/services/monitoring/__init__.py +38 -0
  59. django_cfg/apps/integrations/grpc/services/{monitoring_service.py → monitoring/monitoring.py} +2 -2
  60. django_cfg/apps/integrations/grpc/services/{testing_service.py → monitoring/testing.py} +5 -5
  61. django_cfg/apps/integrations/grpc/services/rendering/__init__.py +27 -0
  62. django_cfg/apps/integrations/grpc/services/{chart_generator.py → rendering/charts.py} +1 -1
  63. django_cfg/apps/integrations/grpc/services/routing/__init__.py +59 -0
  64. django_cfg/apps/integrations/grpc/services/routing/config.py +76 -0
  65. django_cfg/apps/integrations/grpc/services/routing/router.py +430 -0
  66. django_cfg/apps/integrations/grpc/services/streaming/__init__.py +117 -0
  67. django_cfg/apps/integrations/grpc/services/streaming/config.py +451 -0
  68. django_cfg/apps/integrations/grpc/services/streaming/service.py +651 -0
  69. django_cfg/apps/integrations/grpc/services/streaming/types.py +367 -0
  70. django_cfg/apps/integrations/grpc/utils/SERVER_LOGGING.md +164 -0
  71. django_cfg/apps/integrations/grpc/utils/__init__.py +58 -1
  72. django_cfg/apps/integrations/grpc/utils/converters.py +565 -0
  73. django_cfg/apps/integrations/grpc/utils/handlers.py +242 -0
  74. django_cfg/apps/integrations/grpc/utils/proto_gen.py +1 -1
  75. django_cfg/apps/integrations/grpc/utils/streaming_logger.py +261 -13
  76. django_cfg/apps/integrations/grpc/views/charts.py +1 -1
  77. django_cfg/apps/integrations/grpc/views/config.py +1 -1
  78. django_cfg/apps/system/dashboard/serializers/config.py +95 -9
  79. django_cfg/apps/system/dashboard/serializers/statistics.py +9 -4
  80. django_cfg/apps/system/frontend/views.py +87 -6
  81. django_cfg/core/base/config_model.py +11 -0
  82. django_cfg/core/builders/middleware_builder.py +5 -0
  83. django_cfg/core/builders/security_builder.py +1 -0
  84. django_cfg/core/generation/integration_generators/api.py +2 -0
  85. django_cfg/management/commands/pool_status.py +153 -0
  86. django_cfg/middleware/pool_cleanup.py +261 -0
  87. django_cfg/models/api/grpc/config.py +2 -2
  88. django_cfg/models/infrastructure/database/config.py +16 -0
  89. django_cfg/models/infrastructure/database/converters.py +2 -0
  90. django_cfg/modules/django_admin/utils/html/composition.py +57 -13
  91. django_cfg/modules/django_admin/utils/html_builder.py +1 -0
  92. django_cfg/modules/django_client/core/generator/typescript/generator.py +26 -0
  93. django_cfg/modules/django_client/core/generator/typescript/hooks_generator.py +7 -1
  94. django_cfg/modules/django_client/core/generator/typescript/models_generator.py +5 -0
  95. django_cfg/modules/django_client/core/generator/typescript/schemas_generator.py +11 -0
  96. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/fetchers.ts.jinja +1 -0
  97. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +29 -1
  98. django_cfg/modules/django_client/core/generator/typescript/templates/hooks/hooks.ts.jinja +4 -0
  99. django_cfg/modules/django_client/core/groups/manager.py +25 -18
  100. django_cfg/modules/django_client/core/ir/schema.py +15 -1
  101. django_cfg/modules/django_client/core/parser/base.py +12 -0
  102. django_cfg/modules/django_client/management/commands/generate_client.py +9 -5
  103. django_cfg/modules/django_logging/django_logger.py +58 -19
  104. django_cfg/pyproject.toml +3 -3
  105. django_cfg/static/frontend/admin.zip +0 -0
  106. django_cfg/templates/admin/index.html +0 -39
  107. django_cfg/utils/pool_monitor.py +320 -0
  108. django_cfg/utils/smart_defaults.py +233 -7
  109. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/METADATA +75 -5
  110. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/RECORD +118 -74
  111. /django_cfg/apps/integrations/grpc/services/{grpc_client.py → client/client.py} +0 -0
  112. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/errors.py +0 -0
  113. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/logging.py +0 -0
  114. /django_cfg/apps/integrations/grpc/services/{config_helper.py → management/config_helper.py} +0 -0
  115. /django_cfg/apps/integrations/grpc/services/{proto_files_manager.py → management/proto_manager.py} +0 -0
  116. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/WHEEL +0 -0
  117. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/entry_points.txt +0 -0
  118. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,469 @@
1
+ """
2
+ Centrifugo Bridge Mixin for gRPC Services.
3
+
4
+ Universal mixin that enables automatic publishing of gRPC stream events
5
+ to Centrifugo WebSocket channels using Pydantic configuration.
6
+
7
+ Enhanced with:
8
+ - Circuit Breaker pattern for resilience (aiobreaker - async-native)
9
+ - Automatic retry with exponential backoff for critical messages (tenacity)
10
+ - Dead Letter Queue (DLQ) for failed critical messages
11
+ """
12
+
13
+ import asyncio
14
+ import logging
15
+ import time
16
+ from collections import deque
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime, timedelta, timezone as tz
19
+ from typing import Dict, Optional, Any, TYPE_CHECKING, Deque
20
+
21
+ from aiobreaker import CircuitBreaker
22
+ from tenacity import (
23
+ retry,
24
+ stop_after_attempt,
25
+ wait_exponential,
26
+ retry_if_exception_type,
27
+ RetryError,
28
+ )
29
+
30
+ from .config import CentrifugoChannels, ChannelConfig
31
+ from .transformers import transform_protobuf_to_dict
32
+
33
+ if TYPE_CHECKING:
34
+ from django_cfg.apps.integrations.centrifugo import CentrifugoClient
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ @dataclass
40
+ class FailedMessage:
41
+ """Failed Centrifugo message for retry queue."""
42
+ channel: str
43
+ data: Dict[str, Any]
44
+ retry_count: int = 0
45
+ timestamp: float = field(default_factory=time.time)
46
+ field_name: str = ""
47
+
48
+
49
+ class CentrifugoBridgeMixin:
50
+ """
51
+ Universal mixin for publishing gRPC stream events to Centrifugo.
52
+
53
+ Uses Pydantic models for type-safe, validated configuration.
54
+
55
+ Features:
56
+ - Type-safe Pydantic configuration
57
+ - Automatic event publishing to WebSocket channels
58
+ - Built-in protobuf → JSON transformation
59
+ - Graceful degradation if Centrifugo unavailable
60
+ - Custom transform functions support
61
+ - Template-based channel naming
62
+ - Per-channel rate limiting
63
+ - Critical event bypassing
64
+
65
+ Production-Ready Resilience (NEW):
66
+ - Circuit Breaker pattern (fails after 5 errors, recovers after 60s)
67
+ - Automatic retry with exponential backoff for critical messages (3 attempts)
68
+ - Dead Letter Queue (DLQ) for failed critical messages (max 1000 messages)
69
+ - Background retry loop (every 10 seconds)
70
+
71
+ Usage:
72
+ ```python
73
+ from django_cfg.apps.integrations.grpc.mixins import (
74
+ CentrifugoBridgeMixin,
75
+ CentrifugoChannels,
76
+ ChannelConfig,
77
+ )
78
+
79
+ class BotChannels(CentrifugoChannels):
80
+ heartbeat: ChannelConfig = ChannelConfig(
81
+ template='bot#{bot_id}#heartbeat',
82
+ rate_limit=0.1
83
+ )
84
+ status: ChannelConfig = ChannelConfig(
85
+ template='bot#{bot_id}#status',
86
+ critical=True
87
+ )
88
+
89
+ class BotStreamingService(
90
+ bot_streaming_service_pb2_grpc.BotStreamingServiceServicer,
91
+ CentrifugoBridgeMixin
92
+ ):
93
+ centrifugo_channels = BotChannels()
94
+
95
+ async def ConnectBot(self, request_iterator, context):
96
+ async for message in request_iterator:
97
+ # Your business logic
98
+ await self._handle_message(bot_id, message)
99
+
100
+ # Auto-publish to Centrifugo (1 line!)
101
+ await self._notify_centrifugo(message, bot_id=bot_id)
102
+ ```
103
+ """
104
+
105
+ # Class-level Pydantic config (optional, can be set in __init__)
106
+ centrifugo_channels: Optional[CentrifugoChannels] = None
107
+
108
+ def __init__(self):
109
+ """Initialize Centrifugo bridge from Pydantic configuration."""
110
+ super().__init__()
111
+
112
+ # Instance attributes
113
+ self._centrifugo_enabled: bool = False
114
+ self._centrifugo_graceful: bool = True
115
+ self._centrifugo_client: Optional['CentrifugoClient'] = None
116
+ self._centrifugo_mappings: Dict[str, Dict[str, Any]] = {}
117
+ self._centrifugo_last_publish: Dict[str, float] = {}
118
+
119
+ # Circuit Breaker for Centrifugo resilience
120
+ self._circuit_breaker = CircuitBreaker(
121
+ fail_max=5, # Open after 5 consecutive failures
122
+ timeout_duration=timedelta(seconds=60), # Stay open for 60 seconds
123
+ name='centrifugo_bridge'
124
+ )
125
+
126
+ # Dead Letter Queue for failed critical messages (bounded to prevent memory issues)
127
+ self._failed_messages: Deque[FailedMessage] = deque(maxlen=1000)
128
+ self._retry_task: Optional[asyncio.Task] = None
129
+ self._shutdown_event = asyncio.Event()
130
+
131
+ # Auto-setup if config exists
132
+ if self.centrifugo_channels:
133
+ logger.info(f"Setting up Centrifugo bridge with {len(self.centrifugo_channels.get_channel_mappings())} channels")
134
+ self._setup_from_pydantic_config(self.centrifugo_channels)
135
+
136
+ # Don't start background retry task here - will be started lazily on first publish
137
+ # (avoids event loop issues during initialization)
138
+ else:
139
+ logger.debug("No centrifugo_channels configured, skipping Centrifugo bridge setup")
140
+
141
+ def _setup_from_pydantic_config(self, config: CentrifugoChannels):
142
+ """
143
+ Setup Centrifugo bridge from Pydantic configuration.
144
+
145
+ Args:
146
+ config: CentrifugoChannels instance with channel mappings
147
+ """
148
+ self._centrifugo_enabled = config.enabled
149
+ self._centrifugo_graceful = config.graceful_degradation
150
+
151
+ # Extract channel mappings
152
+ for field_name, channel_config in config.get_channel_mappings().items():
153
+ if channel_config.enabled:
154
+ self._centrifugo_mappings[field_name] = {
155
+ 'template': channel_config.template,
156
+ 'rate_limit': channel_config.rate_limit or config.default_rate_limit,
157
+ 'critical': channel_config.critical,
158
+ 'transform': channel_config.transform,
159
+ 'metadata': channel_config.metadata,
160
+ }
161
+
162
+ # Initialize client if enabled
163
+ if self._centrifugo_enabled and self._centrifugo_mappings:
164
+ self._initialize_centrifugo_client()
165
+ else:
166
+ logger.debug(f"Skipping Centrifugo client init: enabled={self._centrifugo_enabled}, mappings={len(self._centrifugo_mappings)}")
167
+
168
+ def _initialize_centrifugo_client(self):
169
+ """Lazy initialize Centrifugo client."""
170
+ try:
171
+ # Use DirectCentrifugoClient for gRPC bridge (bypasses wrapper)
172
+ # Gets settings from django-cfg config automatically via get_centrifugo_config()
173
+ from django_cfg.apps.integrations.centrifugo import DirectCentrifugoClient
174
+
175
+ self._centrifugo_client = DirectCentrifugoClient()
176
+ logger.info(
177
+ f"Centrifugo bridge enabled with {len(self._centrifugo_mappings)} channels"
178
+ )
179
+ except Exception as e:
180
+ logger.warning(f"Centrifugo client not available: {e}")
181
+ if not self._centrifugo_graceful:
182
+ raise
183
+ self._centrifugo_enabled = False
184
+
185
+ def _on_circuit_open(self, breaker, *args, **kwargs):
186
+ """Called when circuit breaker opens (too many failures)."""
187
+ logger.error(
188
+ f"🔴 Centrifugo circuit breaker OPEN: {breaker.fail_counter} failures. "
189
+ f"Blocking publishes for {breaker._reset_timeout}s"
190
+ )
191
+
192
+ def _on_circuit_close(self, breaker, *args, **kwargs):
193
+ """Called when circuit breaker closes (recovered)."""
194
+ logger.info(
195
+ f"🟢 Centrifugo circuit breaker CLOSED: Service recovered"
196
+ )
197
+
198
+ def _ensure_retry_task_started(self):
199
+ """Lazily start background retry task if not already running."""
200
+ if self._retry_task is None and self._centrifugo_enabled:
201
+ try:
202
+ self._retry_task = asyncio.create_task(self._retry_failed_messages_loop())
203
+ logger.debug("Started background retry task for failed messages")
204
+ except RuntimeError:
205
+ # No event loop available yet, will try again later
206
+ logger.debug("Event loop not available yet, retry task will start on next attempt")
207
+
208
+ async def _retry_failed_messages_loop(self):
209
+ """Background task to retry failed critical messages."""
210
+ try:
211
+ while not self._shutdown_event.is_set():
212
+ await asyncio.sleep(10) # Retry every 10 seconds
213
+
214
+ if not self._failed_messages:
215
+ continue
216
+
217
+ queue_size = len(self._failed_messages)
218
+ logger.info(f"Retrying {queue_size} failed Centrifugo messages...")
219
+
220
+ # Process all failed messages
221
+ retry_queue = list(self._failed_messages)
222
+ self._failed_messages.clear()
223
+
224
+ for msg in retry_queue:
225
+ if msg.retry_count >= 3:
226
+ # Max retries exceeded - drop message
227
+ logger.error(
228
+ f"Dropping message after 3 retries: {msg.field_name} → {msg.channel}"
229
+ )
230
+ continue
231
+
232
+ try:
233
+ # Retry with exponential backoff decorator
234
+ await self._publish_with_retry(msg.channel, msg.data)
235
+ logger.info(f"✅ Retry succeeded: {msg.field_name} → {msg.channel}")
236
+
237
+ except (RetryError, Exception) as e:
238
+ # Retry failed, re-queue with incremented counter
239
+ msg.retry_count += 1
240
+ self._failed_messages.append(msg)
241
+ logger.warning(
242
+ f"Retry {msg.retry_count}/3 failed for {msg.field_name}: {e}"
243
+ )
244
+
245
+ except asyncio.CancelledError:
246
+ logger.info("Retry loop cancelled, shutting down...")
247
+ except Exception as e:
248
+ logger.error(f"Error in retry loop: {e}", exc_info=True)
249
+
250
+ @retry(
251
+ retry=retry_if_exception_type((ConnectionError, TimeoutError)),
252
+ stop=stop_after_attempt(3),
253
+ wait=wait_exponential(multiplier=2, min=4, max=30),
254
+ reraise=True,
255
+ )
256
+ async def _publish_with_retry(self, channel: str, data: Dict[str, Any]):
257
+ """Publish to Centrifugo with automatic retry (exponential backoff)."""
258
+ await self._centrifugo_client.publish(channel=channel, data=data)
259
+
260
+ async def shutdown(self):
261
+ """Gracefully shutdown the bridge (cancel retry task)."""
262
+ if self._retry_task:
263
+ self._shutdown_event.set()
264
+ self._retry_task.cancel()
265
+ try:
266
+ await self._retry_task
267
+ except asyncio.CancelledError:
268
+ pass
269
+ logger.info("Centrifugo bridge shutdown complete")
270
+
271
+ async def _notify_centrifugo(
272
+ self,
273
+ message: Any, # Protobuf message
274
+ **context: Any # Template variables for channel rendering
275
+ ) -> bool:
276
+ """
277
+ Publish protobuf message to Centrifugo based on configured mappings.
278
+
279
+ Automatically detects which field is set in the message and publishes
280
+ to the corresponding channel.
281
+
282
+ Args:
283
+ message: Protobuf message (e.g., BotMessage with heartbeat/status/etc.)
284
+ **context: Template variables for channel name rendering
285
+ Example: bot_id='123', user_id='456'
286
+
287
+ Returns:
288
+ True if published successfully, False otherwise
289
+
290
+ Example:
291
+ ```python
292
+ # message = BotMessage with heartbeat field set
293
+ await self._notify_centrifugo(message, bot_id='bot-123')
294
+ # → Publishes to channel: bot#bot-123#heartbeat
295
+ ```
296
+ """
297
+ if not self._centrifugo_enabled or not self._centrifugo_client:
298
+ return False
299
+
300
+ # Lazily start retry task on first publish
301
+ self._ensure_retry_task_started()
302
+
303
+ # Check each mapped field
304
+ for field_name, mapping in self._centrifugo_mappings.items():
305
+ if message.HasField(field_name):
306
+ return await self._publish_field(
307
+ field_name,
308
+ message,
309
+ mapping,
310
+ context
311
+ )
312
+
313
+ return False
314
+
315
+ async def _publish_field(
316
+ self,
317
+ field_name: str,
318
+ message: Any,
319
+ mapping: Dict[str, Any],
320
+ context: dict
321
+ ) -> bool:
322
+ """
323
+ Publish specific message field to Centrifugo with circuit breaker protection.
324
+
325
+ Args:
326
+ field_name: Name of the protobuf field
327
+ message: Full protobuf message
328
+ mapping: Channel mapping configuration
329
+ context: Template variables
330
+
331
+ Returns:
332
+ True if published successfully
333
+ """
334
+ try:
335
+ # Render channel from template
336
+ channel = mapping['template'].format(**context)
337
+
338
+ # Rate limiting check (unless critical)
339
+ if not mapping['critical'] and mapping['rate_limit']:
340
+ now = time.time()
341
+ last = self._centrifugo_last_publish.get(channel, 0)
342
+ if now - last < mapping['rate_limit']:
343
+ logger.debug(f"Rate limit: skipping {field_name} for {channel}")
344
+ return False
345
+ self._centrifugo_last_publish[channel] = now
346
+
347
+ # Get field value
348
+ field_value = getattr(message, field_name)
349
+
350
+ # Transform to dict
351
+ data = self._transform_field(field_name, field_value, mapping, context)
352
+
353
+ # Publish to Centrifugo with circuit breaker protection
354
+ try:
355
+ if mapping['critical']:
356
+ # Critical messages: circuit breaker + retry with exponential backoff
357
+ await self._circuit_breaker.call(
358
+ self._publish_with_retry,
359
+ channel,
360
+ data
361
+ )
362
+ else:
363
+ # Non-critical: only circuit breaker, no retry
364
+ await self._circuit_breaker.call(
365
+ self._centrifugo_client.publish,
366
+ channel=channel,
367
+ data=data
368
+ )
369
+
370
+ logger.debug(f"Published {field_name} to {channel}")
371
+ return True
372
+
373
+ except Exception as circuit_error:
374
+ # Circuit breaker is open or publish failed
375
+ error_name = type(circuit_error).__name__
376
+ if 'CircuitBreakerError' in error_name or 'CircuitBreakerOpen' in error_name:
377
+ logger.warning(
378
+ f"Circuit breaker open, cannot publish {field_name} to {channel}"
379
+ )
380
+
381
+ if mapping['critical']:
382
+ # Queue critical message for background retry
383
+ self._failed_messages.append(FailedMessage(
384
+ channel=channel,
385
+ data=data,
386
+ retry_count=0,
387
+ field_name=field_name
388
+ ))
389
+ logger.info(f"Queued critical message for retry: {field_name}")
390
+
391
+ return False
392
+ else:
393
+ # Re-raise if not circuit breaker error
394
+ raise
395
+
396
+ except KeyError as e:
397
+ logger.error(
398
+ f"Missing template variable in channel: {e}. "
399
+ f"Template: {mapping['template']}, Context: {context}"
400
+ )
401
+ return False
402
+
403
+ except (RetryError, Exception) as e:
404
+ # Publish failed even after retries
405
+ logger.error(
406
+ f"Failed to publish {field_name} to Centrifugo: {e}",
407
+ exc_info=True if not isinstance(e, RetryError) else False
408
+ )
409
+
410
+ # Queue critical messages for background retry
411
+ if mapping['critical']:
412
+ self._failed_messages.append(FailedMessage(
413
+ channel=channel,
414
+ data=data,
415
+ retry_count=0,
416
+ field_name=field_name
417
+ ))
418
+ logger.warning(f"Queued failed critical message: {field_name}")
419
+
420
+ if not self._centrifugo_graceful:
421
+ raise
422
+
423
+ return False
424
+
425
+ def _transform_field(
426
+ self,
427
+ field_name: str,
428
+ field_value: Any,
429
+ mapping: Dict[str, Any],
430
+ context: dict
431
+ ) -> dict:
432
+ """
433
+ Transform protobuf field to JSON-serializable dict.
434
+
435
+ Args:
436
+ field_name: Field name
437
+ field_value: Protobuf message field value
438
+ mapping: Channel mapping with optional transform function
439
+ context: Template context variables
440
+
441
+ Returns:
442
+ JSON-serializable dictionary
443
+ """
444
+ # Use custom transform if provided
445
+ if mapping['transform']:
446
+ data = mapping['transform'](field_name, field_value)
447
+ else:
448
+ # Default protobuf → dict transform
449
+ data = transform_protobuf_to_dict(field_value)
450
+
451
+ # Add metadata
452
+ data['type'] = field_name
453
+ data['timestamp'] = datetime.now(tz.utc).isoformat()
454
+
455
+ # Merge channel metadata
456
+ if mapping['metadata']:
457
+ for key, value in mapping['metadata'].items():
458
+ if key not in data:
459
+ data[key] = value
460
+
461
+ # Add context variables (bot_id, user_id, etc.)
462
+ for key, value in context.items():
463
+ if key not in data:
464
+ data[key] = value
465
+
466
+ return data
467
+
468
+
469
+ __all__ = ["CentrifugoBridgeMixin"]
@@ -0,0 +1,167 @@
1
+ """
2
+ Pydantic Configuration Models for Centrifugo Bridge.
3
+
4
+ Type-safe, validated configuration using Pydantic v2.
5
+ """
6
+
7
+ from pydantic import BaseModel, Field, field_validator
8
+ from typing import Optional, Callable, Any, Dict
9
+
10
+
11
+ class ChannelConfig(BaseModel):
12
+ """
13
+ Configuration for a single gRPC field → Centrifugo channel mapping.
14
+
15
+ Example:
16
+ ```python
17
+ ChannelConfig(
18
+ template='bot#{bot_id}#heartbeat',
19
+ rate_limit=0.1,
20
+ critical=False
21
+ )
22
+ ```
23
+
24
+ Attributes:
25
+ template: Channel name template with {variable} placeholders
26
+ rate_limit: Minimum seconds between publishes (None = no limit)
27
+ critical: Critical events bypass rate limiting
28
+ enabled: Enable/disable this specific channel
29
+ metadata: Additional metadata included in published data
30
+ """
31
+
32
+ template: str = Field(
33
+ ...,
34
+ description="Channel template with {variable} placeholders",
35
+ examples=["bot#{bot_id}#heartbeat", "user#{user_id}#notifications"]
36
+ )
37
+
38
+ rate_limit: Optional[float] = Field(
39
+ None,
40
+ description="Minimum seconds between publishes (None = no limit)",
41
+ ge=0.0,
42
+ examples=[0.1, 1.0, None]
43
+ )
44
+
45
+ critical: bool = Field(
46
+ False,
47
+ description="Critical events bypass rate limiting"
48
+ )
49
+
50
+ enabled: bool = Field(
51
+ True,
52
+ description="Enable/disable this channel"
53
+ )
54
+
55
+ metadata: Dict[str, Any] = Field(
56
+ default_factory=dict,
57
+ description="Additional metadata to include in published data"
58
+ )
59
+
60
+ # Optional custom transform function (not serialized)
61
+ transform: Optional[Callable[[str, Any], dict]] = Field(
62
+ None,
63
+ exclude=True,
64
+ description="Custom transform function(field_name, field_value) -> dict"
65
+ )
66
+
67
+ @field_validator('template')
68
+ @classmethod
69
+ def validate_template(cls, v: str) -> str:
70
+ """Ensure template contains at least one variable placeholder."""
71
+ if '{' not in v or '}' not in v:
72
+ raise ValueError(
73
+ f"Channel template must contain variables like {{bot_id}}: {v}"
74
+ )
75
+ return v
76
+
77
+ model_config = {
78
+ 'arbitrary_types_allowed': True, # Allow Callable types
79
+ 'extra': 'forbid', # Strict validation
80
+ }
81
+
82
+
83
+ class CentrifugoChannels(BaseModel):
84
+ """
85
+ Base configuration model for Centrifugo channel mappings.
86
+
87
+ Inherit from this class to define your channel mappings:
88
+
89
+ Example:
90
+ ```python
91
+ class BotChannels(CentrifugoChannels):
92
+ heartbeat: ChannelConfig = ChannelConfig(
93
+ template='bot#{bot_id}#heartbeat',
94
+ rate_limit=0.1
95
+ )
96
+
97
+ status: ChannelConfig = ChannelConfig(
98
+ template='bot#{bot_id}#status',
99
+ critical=True
100
+ )
101
+
102
+ class BotStreamingService(Servicer, CentrifugoBridgeMixin):
103
+ centrifugo_channels = BotChannels()
104
+ ```
105
+
106
+ Attributes:
107
+ enabled: Enable/disable entire Centrifugo bridge
108
+ default_rate_limit: Default rate limit for all channels
109
+ graceful_degradation: Continue if Centrifugo unavailable
110
+ """
111
+
112
+ enabled: bool = Field(
113
+ True,
114
+ description="Enable/disable entire Centrifugo bridge"
115
+ )
116
+
117
+ default_rate_limit: Optional[float] = Field(
118
+ None,
119
+ description="Default rate limit for all channels (can be overridden per channel)",
120
+ ge=0.0
121
+ )
122
+
123
+ graceful_degradation: bool = Field(
124
+ True,
125
+ description="Continue service operation if Centrifugo is unavailable"
126
+ )
127
+
128
+ def get_channel_mappings(self) -> Dict[str, ChannelConfig]:
129
+ """
130
+ Extract all ChannelConfig fields as dictionary.
131
+
132
+ Returns:
133
+ Dict mapping field names to ChannelConfig instances
134
+
135
+ Example:
136
+ ```python
137
+ channels = BotChannels()
138
+ mappings = channels.get_channel_mappings()
139
+ # {'heartbeat': ChannelConfig(...), 'status': ChannelConfig(...)}
140
+ ```
141
+ """
142
+ mappings = {}
143
+
144
+ for field_name, field_info in self.model_fields.items():
145
+ # Skip base config fields
146
+ if field_name in ('enabled', 'default_rate_limit', 'graceful_degradation'):
147
+ continue
148
+
149
+ # Get field value
150
+ field_value = getattr(self, field_name, None)
151
+
152
+ # Check if it's a ChannelConfig
153
+ if isinstance(field_value, ChannelConfig):
154
+ mappings[field_name] = field_value
155
+
156
+ return mappings
157
+
158
+ model_config = {
159
+ 'extra': 'allow', # Allow additional ChannelConfig fields
160
+ 'arbitrary_types_allowed': True,
161
+ }
162
+
163
+
164
+ __all__ = [
165
+ "ChannelConfig",
166
+ "CentrifugoChannels",
167
+ ]