typedkafka 0.3.1__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.
- typedkafka/__init__.py +53 -0
- typedkafka/admin.py +336 -0
- typedkafka/aio.py +328 -0
- typedkafka/config.py +405 -0
- typedkafka/consumer.py +415 -0
- typedkafka/exceptions.py +130 -0
- typedkafka/producer.py +492 -0
- typedkafka/retry.py +154 -0
- typedkafka/serializers.py +293 -0
- typedkafka/testing.py +523 -0
- typedkafka-0.3.1.dist-info/METADATA +263 -0
- typedkafka-0.3.1.dist-info/RECORD +14 -0
- typedkafka-0.3.1.dist-info/WHEEL +4 -0
- typedkafka-0.3.1.dist-info/licenses/LICENSE +21 -0
typedkafka/producer.py
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kafka Producer with comprehensive documentation and full type safety.
|
|
3
|
+
|
|
4
|
+
This module provides a well-documented, type-hinted wrapper around confluent-kafka's Producer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any, Callable, Optional
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from confluent_kafka import KafkaError as ConfluentKafkaError
|
|
12
|
+
from confluent_kafka import Producer as ConfluentProducer
|
|
13
|
+
except ImportError:
|
|
14
|
+
# Make confluent-kafka optional for documentation/type checking
|
|
15
|
+
ConfluentProducer = None # type: ignore[assignment,misc]
|
|
16
|
+
ConfluentKafkaError = None # type: ignore[assignment,misc]
|
|
17
|
+
|
|
18
|
+
from typedkafka.exceptions import ProducerError, SerializationError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class KafkaProducer:
|
|
22
|
+
"""
|
|
23
|
+
A well-documented Kafka producer with full type hints.
|
|
24
|
+
|
|
25
|
+
This class wraps confluent-kafka's Producer with:
|
|
26
|
+
- Comprehensive docstrings on every method
|
|
27
|
+
- Full type hints for IDE autocomplete
|
|
28
|
+
- Better error messages
|
|
29
|
+
- Convenient methods for common operations (send_json, send_string)
|
|
30
|
+
- Context manager support for automatic cleanup
|
|
31
|
+
|
|
32
|
+
Basic Usage:
|
|
33
|
+
>>> producer = KafkaProducer({"bootstrap.servers": "localhost:9092"})
|
|
34
|
+
>>> producer.send("my-topic", b"my message", key=b"my-key")
|
|
35
|
+
>>> producer.flush() # Wait for all messages to be delivered
|
|
36
|
+
|
|
37
|
+
With Context Manager:
|
|
38
|
+
>>> with KafkaProducer({"bootstrap.servers": "localhost:9092"}) as producer:
|
|
39
|
+
... producer.send("my-topic", b"message")
|
|
40
|
+
... # Automatic flush and cleanup on exit
|
|
41
|
+
|
|
42
|
+
JSON Messages:
|
|
43
|
+
>>> producer.send_json("events", {"user_id": 123, "action": "click"})
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
config: The configuration dictionary used to initialize the producer
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, config: dict[str, Any]):
|
|
50
|
+
"""
|
|
51
|
+
Initialize a Kafka producer with the given configuration.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
config: Configuration dictionary for the producer. Common options:
|
|
55
|
+
- bootstrap.servers (str): Comma-separated list of broker addresses
|
|
56
|
+
Example: "localhost:9092" or "broker1:9092,broker2:9092"
|
|
57
|
+
- client.id (str): An identifier for this client
|
|
58
|
+
- acks (str|int): Number of acknowledgments the producer requires
|
|
59
|
+
"0" = no acknowledgment, "1" = leader only, "all" = all replicas
|
|
60
|
+
- compression.type (str): Compression codec ("none", "gzip", "snappy", "lz4", "zstd")
|
|
61
|
+
- max.in.flight.requests.per.connection (int): Max unacknowledged requests
|
|
62
|
+
- linger.ms (int): Time to wait before sending a batch
|
|
63
|
+
- batch.size (int): Maximum size of a message batch in bytes
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ProducerError: If the producer cannot be initialized with the given config
|
|
67
|
+
|
|
68
|
+
Examples:
|
|
69
|
+
>>> # Basic producer
|
|
70
|
+
>>> producer = KafkaProducer({"bootstrap.servers": "localhost:9092"})
|
|
71
|
+
|
|
72
|
+
>>> # Producer with compression
|
|
73
|
+
>>> producer = KafkaProducer({
|
|
74
|
+
... "bootstrap.servers": "localhost:9092",
|
|
75
|
+
... "compression.type": "gzip",
|
|
76
|
+
... "acks": "all"
|
|
77
|
+
... })
|
|
78
|
+
"""
|
|
79
|
+
if ConfluentProducer is None:
|
|
80
|
+
raise ImportError(
|
|
81
|
+
"confluent-kafka is required. Install with: pip install confluent-kafka"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
self.config = config
|
|
85
|
+
try:
|
|
86
|
+
self._producer = ConfluentProducer(config)
|
|
87
|
+
except Exception as e:
|
|
88
|
+
raise ProducerError(
|
|
89
|
+
f"Failed to initialize Kafka producer: {e}",
|
|
90
|
+
original_error=e,
|
|
91
|
+
) from e
|
|
92
|
+
|
|
93
|
+
def send(
|
|
94
|
+
self,
|
|
95
|
+
topic: str,
|
|
96
|
+
value: bytes,
|
|
97
|
+
key: Optional[bytes] = None,
|
|
98
|
+
partition: Optional[int] = None,
|
|
99
|
+
on_delivery: Optional[Callable[[Any, Any], None]] = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""
|
|
102
|
+
Send a message to a Kafka topic.
|
|
103
|
+
|
|
104
|
+
This method is asynchronous - it returns immediately after queuing the message.
|
|
105
|
+
Use flush() to wait for delivery confirmation.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
topic: The topic name to send the message to
|
|
109
|
+
value: The message payload as bytes
|
|
110
|
+
key: Optional message key as bytes. Messages with the same key go to the same partition.
|
|
111
|
+
partition: Optional partition number. If None, partition is chosen by the partitioner.
|
|
112
|
+
on_delivery: Optional callback function called when delivery succeeds or fails.
|
|
113
|
+
Signature: callback(error, message)
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
ProducerError: If the message cannot be queued (e.g., queue is full)
|
|
117
|
+
|
|
118
|
+
Examples:
|
|
119
|
+
>>> # Send a simple message
|
|
120
|
+
>>> producer.send("my-topic", b"Hello, Kafka!")
|
|
121
|
+
|
|
122
|
+
>>> # Send with a key for partitioning
|
|
123
|
+
>>> producer.send("user-events", b"event data", key=b"user-123")
|
|
124
|
+
|
|
125
|
+
>>> # Send to a specific partition
|
|
126
|
+
>>> producer.send("my-topic", b"data", partition=0)
|
|
127
|
+
|
|
128
|
+
>>> # Send with delivery callback
|
|
129
|
+
>>> def on_delivery(err, msg):
|
|
130
|
+
... if err:
|
|
131
|
+
... print(f"Delivery failed: {err}")
|
|
132
|
+
... else:
|
|
133
|
+
... print(f"Delivered to {msg.topic()} [{msg.partition()}]")
|
|
134
|
+
>>> producer.send("topic", b"data", on_delivery=on_delivery)
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
self._producer.produce(
|
|
138
|
+
topic=topic,
|
|
139
|
+
value=value,
|
|
140
|
+
key=key,
|
|
141
|
+
partition=partition, # type: ignore[arg-type]
|
|
142
|
+
on_delivery=on_delivery,
|
|
143
|
+
)
|
|
144
|
+
# Poll to trigger callbacks and handle backpressure
|
|
145
|
+
self._producer.poll(0)
|
|
146
|
+
except BufferError as e:
|
|
147
|
+
raise ProducerError(
|
|
148
|
+
"Message queue is full. Try calling flush() or increasing queue.buffering.max.messages",
|
|
149
|
+
original_error=e,
|
|
150
|
+
) from e
|
|
151
|
+
except Exception as e:
|
|
152
|
+
raise ProducerError(
|
|
153
|
+
f"Failed to send message to topic '{topic}': {e}",
|
|
154
|
+
original_error=e,
|
|
155
|
+
) from e
|
|
156
|
+
|
|
157
|
+
def send_json(
|
|
158
|
+
self,
|
|
159
|
+
topic: str,
|
|
160
|
+
value: Any,
|
|
161
|
+
key: Optional[str] = None,
|
|
162
|
+
partition: Optional[int] = None,
|
|
163
|
+
on_delivery: Optional[Callable[[Any, Any], None]] = None,
|
|
164
|
+
) -> None:
|
|
165
|
+
"""
|
|
166
|
+
Send a JSON-serialized message to a Kafka topic.
|
|
167
|
+
|
|
168
|
+
Convenience method that automatically serializes Python objects to JSON.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
topic: The topic name to send the message to
|
|
172
|
+
value: Any JSON-serializable Python object (dict, list, str, int, etc.)
|
|
173
|
+
key: Optional string key (will be UTF-8 encoded)
|
|
174
|
+
partition: Optional partition number
|
|
175
|
+
on_delivery: Optional callback function for delivery confirmation
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
SerializationError: If the value cannot be serialized to JSON
|
|
179
|
+
ProducerError: If the message cannot be queued
|
|
180
|
+
|
|
181
|
+
Examples:
|
|
182
|
+
>>> # Send a dict as JSON
|
|
183
|
+
>>> producer.send_json("events", {"user_id": 123, "action": "click"})
|
|
184
|
+
|
|
185
|
+
>>> # Send with a string key
|
|
186
|
+
>>> producer.send_json("user-data", {"name": "Alice"}, key="user-123")
|
|
187
|
+
|
|
188
|
+
>>> # Send a list
|
|
189
|
+
>>> producer.send_json("numbers", [1, 2, 3, 4, 5])
|
|
190
|
+
"""
|
|
191
|
+
try:
|
|
192
|
+
value_bytes = json.dumps(value).encode("utf-8")
|
|
193
|
+
except (TypeError, ValueError) as e:
|
|
194
|
+
raise SerializationError(
|
|
195
|
+
f"Failed to serialize value to JSON: {e}",
|
|
196
|
+
value=value,
|
|
197
|
+
original_error=e,
|
|
198
|
+
) from e
|
|
199
|
+
|
|
200
|
+
key_bytes = key.encode("utf-8") if key is not None else None
|
|
201
|
+
self.send(topic, value_bytes, key=key_bytes, partition=partition, on_delivery=on_delivery)
|
|
202
|
+
|
|
203
|
+
def send_string(
|
|
204
|
+
self,
|
|
205
|
+
topic: str,
|
|
206
|
+
value: str,
|
|
207
|
+
key: Optional[str] = None,
|
|
208
|
+
partition: Optional[int] = None,
|
|
209
|
+
on_delivery: Optional[Callable[[Any, Any], None]] = None,
|
|
210
|
+
) -> None:
|
|
211
|
+
"""
|
|
212
|
+
Send a UTF-8 encoded string message to a Kafka topic.
|
|
213
|
+
|
|
214
|
+
Convenience method for sending text messages.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
topic: The topic name to send the message to
|
|
218
|
+
value: String message to send
|
|
219
|
+
key: Optional string key
|
|
220
|
+
partition: Optional partition number
|
|
221
|
+
on_delivery: Optional callback function for delivery confirmation
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
ProducerError: If the message cannot be queued
|
|
225
|
+
|
|
226
|
+
Examples:
|
|
227
|
+
>>> producer.send_string("logs", "Application started successfully")
|
|
228
|
+
>>> producer.send_string("user-messages", "Hello!", key="user-123")
|
|
229
|
+
"""
|
|
230
|
+
value_bytes = value.encode("utf-8")
|
|
231
|
+
key_bytes = key.encode("utf-8") if key is not None else None
|
|
232
|
+
self.send(topic, value_bytes, key=key_bytes, partition=partition, on_delivery=on_delivery)
|
|
233
|
+
|
|
234
|
+
def flush(self, timeout: float = -1) -> int:
|
|
235
|
+
"""
|
|
236
|
+
Wait for all messages in the queue to be delivered.
|
|
237
|
+
|
|
238
|
+
Blocks until all messages are sent or the timeout expires.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
timeout: Maximum time to wait in seconds. Use -1 for infinite wait (default).
|
|
242
|
+
Example: 5.0 = wait up to 5 seconds
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Number of messages still in queue/internal Producer state. 0 means all delivered.
|
|
246
|
+
|
|
247
|
+
Raises:
|
|
248
|
+
ProducerError: If flush fails
|
|
249
|
+
|
|
250
|
+
Examples:
|
|
251
|
+
>>> # Wait for all messages to be delivered
|
|
252
|
+
>>> producer.send("topic", b"message 1")
|
|
253
|
+
>>> producer.send("topic", b"message 2")
|
|
254
|
+
>>> remaining = producer.flush()
|
|
255
|
+
>>> if remaining == 0:
|
|
256
|
+
... print("All messages delivered successfully")
|
|
257
|
+
|
|
258
|
+
>>> # Wait up to 5 seconds
|
|
259
|
+
>>> remaining = producer.flush(timeout=5.0)
|
|
260
|
+
>>> if remaining > 0:
|
|
261
|
+
... print(f"Warning: {remaining} messages not delivered after 5 seconds")
|
|
262
|
+
"""
|
|
263
|
+
try:
|
|
264
|
+
return self._producer.flush(timeout=timeout) # type: ignore[no-any-return]
|
|
265
|
+
except Exception as e:
|
|
266
|
+
raise ProducerError(f"Flush failed: {e}", original_error=e) from e
|
|
267
|
+
|
|
268
|
+
def close(self) -> None:
|
|
269
|
+
"""
|
|
270
|
+
Close the producer and release resources.
|
|
271
|
+
|
|
272
|
+
Calls flush() to ensure all queued messages are delivered before closing.
|
|
273
|
+
It's recommended to use the producer as a context manager instead of calling
|
|
274
|
+
this method directly.
|
|
275
|
+
|
|
276
|
+
Examples:
|
|
277
|
+
>>> producer = KafkaProducer({"bootstrap.servers": "localhost:9092"})
|
|
278
|
+
>>> try:
|
|
279
|
+
... producer.send("topic", b"message")
|
|
280
|
+
... finally:
|
|
281
|
+
... producer.close() # Ensure cleanup
|
|
282
|
+
|
|
283
|
+
>>> # Better: use context manager
|
|
284
|
+
>>> with KafkaProducer({"bootstrap.servers": "localhost:9092"}) as producer:
|
|
285
|
+
... producer.send("topic", b"message")
|
|
286
|
+
"""
|
|
287
|
+
self.flush()
|
|
288
|
+
|
|
289
|
+
def __enter__(self) -> "KafkaProducer":
|
|
290
|
+
"""
|
|
291
|
+
Enter context manager.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
self
|
|
295
|
+
|
|
296
|
+
Examples:
|
|
297
|
+
>>> with KafkaProducer({"bootstrap.servers": "localhost:9092"}) as producer:
|
|
298
|
+
... producer.send("topic", b"message")
|
|
299
|
+
"""
|
|
300
|
+
return self
|
|
301
|
+
|
|
302
|
+
def send_batch(
|
|
303
|
+
self,
|
|
304
|
+
topic: str,
|
|
305
|
+
messages: list[tuple[bytes, Optional[bytes]]],
|
|
306
|
+
on_delivery: Optional[Callable[[Any, Any], None]] = None,
|
|
307
|
+
) -> None:
|
|
308
|
+
"""
|
|
309
|
+
Send a batch of messages to a Kafka topic.
|
|
310
|
+
|
|
311
|
+
Each message is a tuple of (value, key). This is more efficient than
|
|
312
|
+
calling send() repeatedly as it defers polling until after all messages
|
|
313
|
+
are queued.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
topic: The topic name to send the messages to
|
|
317
|
+
messages: List of (value, key) tuples. Key can be None.
|
|
318
|
+
on_delivery: Optional callback for each message delivery.
|
|
319
|
+
|
|
320
|
+
Raises:
|
|
321
|
+
ProducerError: If any message cannot be queued
|
|
322
|
+
|
|
323
|
+
Examples:
|
|
324
|
+
>>> producer.send_batch("events", [
|
|
325
|
+
... (b"event1", b"key1"),
|
|
326
|
+
... (b"event2", b"key2"),
|
|
327
|
+
... (b"event3", None),
|
|
328
|
+
... ])
|
|
329
|
+
>>> producer.flush()
|
|
330
|
+
"""
|
|
331
|
+
for value, key in messages:
|
|
332
|
+
try:
|
|
333
|
+
self._producer.produce(
|
|
334
|
+
topic=topic,
|
|
335
|
+
value=value,
|
|
336
|
+
key=key,
|
|
337
|
+
on_delivery=on_delivery,
|
|
338
|
+
)
|
|
339
|
+
except BufferError:
|
|
340
|
+
# Flush and retry once on buffer full
|
|
341
|
+
self._producer.flush()
|
|
342
|
+
try:
|
|
343
|
+
self._producer.produce(
|
|
344
|
+
topic=topic,
|
|
345
|
+
value=value,
|
|
346
|
+
key=key,
|
|
347
|
+
on_delivery=on_delivery,
|
|
348
|
+
)
|
|
349
|
+
except Exception as retry_e:
|
|
350
|
+
raise ProducerError(
|
|
351
|
+
f"Failed to send message to topic '{topic}' after flush: {retry_e}",
|
|
352
|
+
original_error=retry_e,
|
|
353
|
+
) from retry_e
|
|
354
|
+
except Exception as e:
|
|
355
|
+
raise ProducerError(
|
|
356
|
+
f"Failed to send message to topic '{topic}': {e}",
|
|
357
|
+
original_error=e,
|
|
358
|
+
) from e
|
|
359
|
+
# Poll to trigger callbacks
|
|
360
|
+
self._producer.poll(0)
|
|
361
|
+
|
|
362
|
+
def init_transactions(self, timeout: float = 30.0) -> None:
|
|
363
|
+
"""
|
|
364
|
+
Initialize the producer for transactions.
|
|
365
|
+
|
|
366
|
+
Must be called before any transactional methods. Requires the
|
|
367
|
+
``transactional.id`` configuration to be set.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
timeout: Maximum time to wait for initialization in seconds.
|
|
371
|
+
|
|
372
|
+
Raises:
|
|
373
|
+
ProducerError: If transaction initialization fails.
|
|
374
|
+
|
|
375
|
+
Examples:
|
|
376
|
+
>>> producer = KafkaProducer({
|
|
377
|
+
... "bootstrap.servers": "localhost:9092",
|
|
378
|
+
... "transactional.id": "my-txn-id",
|
|
379
|
+
... })
|
|
380
|
+
>>> producer.init_transactions()
|
|
381
|
+
"""
|
|
382
|
+
try:
|
|
383
|
+
self._producer.init_transactions(timeout)
|
|
384
|
+
except Exception as e:
|
|
385
|
+
raise ProducerError(
|
|
386
|
+
f"Failed to initialize transactions: {e}",
|
|
387
|
+
original_error=e,
|
|
388
|
+
) from e
|
|
389
|
+
|
|
390
|
+
def begin_transaction(self) -> None:
|
|
391
|
+
"""
|
|
392
|
+
Begin a new transaction.
|
|
393
|
+
|
|
394
|
+
Raises:
|
|
395
|
+
ProducerError: If beginning the transaction fails.
|
|
396
|
+
"""
|
|
397
|
+
try:
|
|
398
|
+
self._producer.begin_transaction()
|
|
399
|
+
except Exception as e:
|
|
400
|
+
raise ProducerError(
|
|
401
|
+
f"Failed to begin transaction: {e}",
|
|
402
|
+
original_error=e,
|
|
403
|
+
) from e
|
|
404
|
+
|
|
405
|
+
def commit_transaction(self, timeout: float = 30.0) -> None:
|
|
406
|
+
"""
|
|
407
|
+
Commit the current transaction.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
timeout: Maximum time to wait for commit in seconds.
|
|
411
|
+
|
|
412
|
+
Raises:
|
|
413
|
+
ProducerError: If committing the transaction fails.
|
|
414
|
+
"""
|
|
415
|
+
try:
|
|
416
|
+
self._producer.commit_transaction(timeout)
|
|
417
|
+
except Exception as e:
|
|
418
|
+
raise ProducerError(
|
|
419
|
+
f"Failed to commit transaction: {e}",
|
|
420
|
+
original_error=e,
|
|
421
|
+
) from e
|
|
422
|
+
|
|
423
|
+
def abort_transaction(self, timeout: float = 30.0) -> None:
|
|
424
|
+
"""
|
|
425
|
+
Abort the current transaction.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
timeout: Maximum time to wait for abort in seconds.
|
|
429
|
+
|
|
430
|
+
Raises:
|
|
431
|
+
ProducerError: If aborting the transaction fails.
|
|
432
|
+
"""
|
|
433
|
+
try:
|
|
434
|
+
self._producer.abort_transaction(timeout)
|
|
435
|
+
except Exception as e:
|
|
436
|
+
raise ProducerError(
|
|
437
|
+
f"Failed to abort transaction: {e}",
|
|
438
|
+
original_error=e,
|
|
439
|
+
) from e
|
|
440
|
+
|
|
441
|
+
def transaction(self) -> "TransactionContext":
|
|
442
|
+
"""
|
|
443
|
+
Return a context manager for transactional sends.
|
|
444
|
+
|
|
445
|
+
Automatically begins, commits, or aborts the transaction.
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
A context manager that manages the transaction lifecycle.
|
|
449
|
+
|
|
450
|
+
Raises:
|
|
451
|
+
ProducerError: If any transaction operation fails.
|
|
452
|
+
|
|
453
|
+
Examples:
|
|
454
|
+
>>> producer = KafkaProducer({
|
|
455
|
+
... "bootstrap.servers": "localhost:9092",
|
|
456
|
+
... "transactional.id": "my-txn-id",
|
|
457
|
+
... })
|
|
458
|
+
>>> producer.init_transactions()
|
|
459
|
+
>>> with producer.transaction():
|
|
460
|
+
... producer.send("topic", b"msg1")
|
|
461
|
+
... producer.send("topic", b"msg2")
|
|
462
|
+
"""
|
|
463
|
+
return TransactionContext(self)
|
|
464
|
+
|
|
465
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
466
|
+
"""
|
|
467
|
+
Exit context manager and cleanup resources.
|
|
468
|
+
|
|
469
|
+
Automatically flushes all pending messages before exiting.
|
|
470
|
+
"""
|
|
471
|
+
self.close()
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
class TransactionContext:
|
|
475
|
+
"""Context manager for Kafka transactions.
|
|
476
|
+
|
|
477
|
+
Begins a transaction on entry and commits on clean exit.
|
|
478
|
+
Aborts the transaction if an exception occurs.
|
|
479
|
+
"""
|
|
480
|
+
|
|
481
|
+
def __init__(self, producer: KafkaProducer):
|
|
482
|
+
self._producer = producer
|
|
483
|
+
|
|
484
|
+
def __enter__(self) -> "TransactionContext":
|
|
485
|
+
self._producer.begin_transaction()
|
|
486
|
+
return self
|
|
487
|
+
|
|
488
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
489
|
+
if exc_type is not None:
|
|
490
|
+
self._producer.abort_transaction()
|
|
491
|
+
else:
|
|
492
|
+
self._producer.commit_transaction()
|
typedkafka/retry.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Retry and backoff utilities for Kafka operations.
|
|
3
|
+
|
|
4
|
+
Provides decorators and helpers for retrying transient Kafka failures
|
|
5
|
+
with configurable backoff strategies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import functools
|
|
9
|
+
import random
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from typing import Any, Callable, Optional, TypeVar
|
|
13
|
+
|
|
14
|
+
from typedkafka.exceptions import KafkaError
|
|
15
|
+
|
|
16
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def retry(
|
|
20
|
+
max_attempts: int = 3,
|
|
21
|
+
backoff_base: float = 0.5,
|
|
22
|
+
backoff_max: float = 30.0,
|
|
23
|
+
jitter: bool = True,
|
|
24
|
+
retryable_exceptions: Optional[Sequence[type[BaseException]]] = None,
|
|
25
|
+
) -> Callable[[F], F]:
|
|
26
|
+
"""
|
|
27
|
+
Decorator that retries a function on failure with exponential backoff.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
max_attempts: Maximum number of attempts (including the first call).
|
|
31
|
+
backoff_base: Base delay in seconds for exponential backoff.
|
|
32
|
+
backoff_max: Maximum delay in seconds between retries.
|
|
33
|
+
jitter: If True, add random jitter to backoff delay.
|
|
34
|
+
retryable_exceptions: Exception types to retry on.
|
|
35
|
+
Defaults to ``(KafkaError,)`` if not specified.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Decorated function that retries on failure.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
The last exception if all attempts fail.
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
>>> from typedkafka.retry import retry
|
|
45
|
+
>>> from typedkafka.exceptions import ProducerError
|
|
46
|
+
>>>
|
|
47
|
+
>>> @retry(max_attempts=3, backoff_base=1.0)
|
|
48
|
+
... def send_message(producer, topic, value):
|
|
49
|
+
... producer.send(topic, value)
|
|
50
|
+
... producer.flush()
|
|
51
|
+
|
|
52
|
+
>>> # Retry only specific exceptions
|
|
53
|
+
>>> @retry(max_attempts=5, retryable_exceptions=(ProducerError,))
|
|
54
|
+
... def produce(producer, data):
|
|
55
|
+
... producer.send_json("events", data)
|
|
56
|
+
"""
|
|
57
|
+
if retryable_exceptions is None:
|
|
58
|
+
retryable_exceptions = (KafkaError,)
|
|
59
|
+
|
|
60
|
+
retry_tuple = tuple(retryable_exceptions)
|
|
61
|
+
|
|
62
|
+
def decorator(func: F) -> F:
|
|
63
|
+
@functools.wraps(func)
|
|
64
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
65
|
+
last_exception: Optional[BaseException] = None
|
|
66
|
+
for attempt in range(max_attempts):
|
|
67
|
+
try:
|
|
68
|
+
return func(*args, **kwargs)
|
|
69
|
+
except retry_tuple as e:
|
|
70
|
+
last_exception = e
|
|
71
|
+
if attempt < max_attempts - 1:
|
|
72
|
+
delay = min(backoff_base * (2 ** attempt), backoff_max)
|
|
73
|
+
if jitter:
|
|
74
|
+
delay = delay * (0.5 + random.random() * 0.5) # noqa: S311
|
|
75
|
+
time.sleep(delay)
|
|
76
|
+
raise last_exception # type: ignore[misc]
|
|
77
|
+
|
|
78
|
+
return wrapper # type: ignore[return-value]
|
|
79
|
+
|
|
80
|
+
return decorator
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class RetryPolicy:
|
|
84
|
+
"""
|
|
85
|
+
Configurable retry policy for Kafka operations.
|
|
86
|
+
|
|
87
|
+
Provides a reusable retry configuration that can be applied to
|
|
88
|
+
multiple operations.
|
|
89
|
+
|
|
90
|
+
Examples:
|
|
91
|
+
>>> policy = RetryPolicy(max_attempts=5, backoff_base=1.0)
|
|
92
|
+
>>> result = policy.execute(lambda: producer.send("topic", b"msg"))
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
max_attempts: int = 3,
|
|
98
|
+
backoff_base: float = 0.5,
|
|
99
|
+
backoff_max: float = 30.0,
|
|
100
|
+
jitter: bool = True,
|
|
101
|
+
retryable_exceptions: Optional[Sequence[type[BaseException]]] = None,
|
|
102
|
+
):
|
|
103
|
+
"""
|
|
104
|
+
Initialize a retry policy.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
max_attempts: Maximum number of attempts.
|
|
108
|
+
backoff_base: Base delay in seconds for exponential backoff.
|
|
109
|
+
backoff_max: Maximum delay between retries.
|
|
110
|
+
jitter: If True, add random jitter to delays.
|
|
111
|
+
retryable_exceptions: Exception types to retry on.
|
|
112
|
+
Defaults to ``(KafkaError,)``.
|
|
113
|
+
"""
|
|
114
|
+
self.max_attempts = max_attempts
|
|
115
|
+
self.backoff_base = backoff_base
|
|
116
|
+
self.backoff_max = backoff_max
|
|
117
|
+
self.jitter = jitter
|
|
118
|
+
self.retryable_exceptions: tuple[type[BaseException], ...] = tuple(
|
|
119
|
+
retryable_exceptions or (KafkaError,)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def execute(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
|
123
|
+
"""
|
|
124
|
+
Execute a function with retry logic.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
func: The function to execute.
|
|
128
|
+
*args: Positional arguments to pass to the function.
|
|
129
|
+
**kwargs: Keyword arguments to pass to the function.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The return value of the function.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
The last exception if all attempts fail.
|
|
136
|
+
|
|
137
|
+
Examples:
|
|
138
|
+
>>> policy = RetryPolicy(max_attempts=3)
|
|
139
|
+
>>> policy.execute(producer.send, "topic", b"value")
|
|
140
|
+
"""
|
|
141
|
+
last_exception: Optional[BaseException] = None
|
|
142
|
+
for attempt in range(self.max_attempts):
|
|
143
|
+
try:
|
|
144
|
+
return func(*args, **kwargs)
|
|
145
|
+
except self.retryable_exceptions as e:
|
|
146
|
+
last_exception = e
|
|
147
|
+
if attempt < self.max_attempts - 1:
|
|
148
|
+
delay = min(
|
|
149
|
+
self.backoff_base * (2 ** attempt), self.backoff_max
|
|
150
|
+
)
|
|
151
|
+
if self.jitter:
|
|
152
|
+
delay = delay * (0.5 + random.random() * 0.5) # noqa: S311
|
|
153
|
+
time.sleep(delay)
|
|
154
|
+
raise last_exception # type: ignore[misc]
|