Flask-RMQ 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- examples/basic_app/basic_app/__init__.py +3 -0
- examples/basic_app/basic_app/app.py +35 -0
- examples/basic_app/basic_app/messaging.py +48 -0
- examples/basic_app/basic_app/views.py +28 -0
- flask_rmq/__init__.py +28 -0
- flask_rmq/cli.py +111 -0
- flask_rmq/config.py +55 -0
- flask_rmq/connections.py +84 -0
- flask_rmq/consumer.py +137 -0
- flask_rmq/exceptions.py +10 -0
- flask_rmq/extension.py +53 -0
- flask_rmq/observability.py +119 -0
- flask_rmq/producer.py +104 -0
- flask_rmq/py.typed +0 -0
- flask_rmq/queues.py +24 -0
- flask_rmq/registries/__init__.py +4 -0
- flask_rmq/registries/consumers.py +25 -0
- flask_rmq/registries/setup.py +34 -0
- flask_rmq/state.py +62 -0
- flask_rmq-0.1.0.dist-info/METADATA +174 -0
- flask_rmq-0.1.0.dist-info/RECORD +31 -0
- flask_rmq-0.1.0.dist-info/WHEEL +5 -0
- flask_rmq-0.1.0.dist-info/licenses/LICENSE +21 -0
- flask_rmq-0.1.0.dist-info/top_level.txt +3 -0
- tests/conftest.py +13 -0
- tests/test_config.py +24 -0
- tests/test_consumer.py +40 -0
- tests/test_extension.py +28 -0
- tests/test_producer.py +58 -0
- tests/test_queues.py +14 -0
- tests/test_registries.py +22 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from flask import Flask
|
|
6
|
+
|
|
7
|
+
from flask_rmq import FlaskRMQ
|
|
8
|
+
|
|
9
|
+
from .messaging import register_messaging
|
|
10
|
+
from .views import api
|
|
11
|
+
|
|
12
|
+
rmq = FlaskRMQ()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_app() -> Flask:
|
|
16
|
+
app = Flask(__name__)
|
|
17
|
+
app.config['RABBITMQ_CONNECTIONS'] = {
|
|
18
|
+
'default': {
|
|
19
|
+
'HOST': os.getenv('RABBITMQ_HOST', 'localhost'),
|
|
20
|
+
'PORT': int(os.getenv('RABBITMQ_PORT', '5672')),
|
|
21
|
+
'VIRTUAL_HOST': os.getenv('RABBITMQ_VHOST', '/'),
|
|
22
|
+
'USER': os.getenv('RABBITMQ_USER', 'guest'),
|
|
23
|
+
'PASSWORD': os.getenv('RABBITMQ_PASSWORD', 'guest'),
|
|
24
|
+
'HEARTBEAT': 60,
|
|
25
|
+
'BLOCKED_CONNECTION_TIMEOUT': 30,
|
|
26
|
+
'RECONNECT_INITIAL_BACKOFF': 1,
|
|
27
|
+
'RECONNECT_MAX_BACKOFF': 15,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
app.config['CONSUMED_FILE'] = os.getenv('CONSUMED_FILE', 'consumed.jsonl')
|
|
31
|
+
rmq.init_app(app)
|
|
32
|
+
app.register_blueprint(api)
|
|
33
|
+
with app.app_context():
|
|
34
|
+
register_messaging()
|
|
35
|
+
return app
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from flask import current_app
|
|
6
|
+
from pika.adapters.blocking_connection import BlockingChannel
|
|
7
|
+
|
|
8
|
+
from flask_rmq import Consumer, Producer, QueueConfig, get_consumers_registry, get_setup_registry
|
|
9
|
+
|
|
10
|
+
EVENTS_QUEUE = QueueConfig(
|
|
11
|
+
name='flask_rmq.events',
|
|
12
|
+
durable=True,
|
|
13
|
+
dead_letter_exchange='flask_rmq.dlx',
|
|
14
|
+
dead_letter_routing_key='flask_rmq.events.failed',
|
|
15
|
+
)
|
|
16
|
+
producer = Producer(exchange='flask_rmq.events', queue=EVENTS_QUEUE)
|
|
17
|
+
consumer = Consumer(queue=EVENTS_QUEUE)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def setup_topology(channel: BlockingChannel) -> None:
|
|
21
|
+
channel.exchange_declare(exchange='flask_rmq.events', exchange_type='direct', durable=True)
|
|
22
|
+
channel.exchange_declare(exchange='flask_rmq.dlx', exchange_type='direct', durable=True)
|
|
23
|
+
channel.queue_declare(queue=EVENTS_QUEUE.name, durable=True, arguments=EVENTS_QUEUE.arguments)
|
|
24
|
+
channel.queue_bind(
|
|
25
|
+
exchange='flask_rmq.events',
|
|
26
|
+
queue=EVENTS_QUEUE.name,
|
|
27
|
+
routing_key=EVENTS_QUEUE.name,
|
|
28
|
+
)
|
|
29
|
+
channel.queue_declare(queue='flask_rmq.events.failed', durable=True)
|
|
30
|
+
channel.queue_bind(
|
|
31
|
+
exchange='flask_rmq.dlx',
|
|
32
|
+
queue='flask_rmq.events.failed',
|
|
33
|
+
routing_key='flask_rmq.events.failed',
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@consumer
|
|
38
|
+
def save_event(channel, method, properties, body: bytes) -> None:
|
|
39
|
+
output = Path(current_app.config['CONSUMED_FILE'])
|
|
40
|
+
with output.open('ab') as stream:
|
|
41
|
+
stream.write(body + b'\n')
|
|
42
|
+
current_app.logger.info('Consumed: %s', body.decode('utf-8'))
|
|
43
|
+
channel.basic_ack(delivery_tag=method.delivery_tag)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def register_messaging() -> None:
|
|
47
|
+
get_setup_registry().register(setup_topology)
|
|
48
|
+
get_consumers_registry().register(consumer)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
from flask import Blueprint, jsonify, request
|
|
8
|
+
|
|
9
|
+
from .messaging import producer
|
|
10
|
+
|
|
11
|
+
api = Blueprint('api', __name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@api.get('/health')
|
|
15
|
+
def health():
|
|
16
|
+
return {'status': 'ok'}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@api.post('/events')
|
|
20
|
+
def publish_event():
|
|
21
|
+
incoming = request.get_json(silent=True) or {}
|
|
22
|
+
event = {
|
|
23
|
+
'id': str(uuid4()),
|
|
24
|
+
'created_at': datetime.now(UTC).isoformat(),
|
|
25
|
+
'payload': incoming,
|
|
26
|
+
}
|
|
27
|
+
producer.publish(json.dumps(event))
|
|
28
|
+
return jsonify(event), 202
|
flask_rmq/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from flask_rmq.consumer import Consumer, MessageCallback
|
|
2
|
+
from flask_rmq.exceptions import ConfigurationError, FlaskRMQError, NotInitializedError
|
|
3
|
+
from flask_rmq.extension import FlaskRMQ
|
|
4
|
+
from flask_rmq.producer import Producer
|
|
5
|
+
from flask_rmq.queues import QueueConfig
|
|
6
|
+
from flask_rmq.registries import (
|
|
7
|
+
ConsumersRegistry,
|
|
8
|
+
SetupRegistry,
|
|
9
|
+
get_consumers_registry,
|
|
10
|
+
get_setup_registry,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
'ConfigurationError',
|
|
15
|
+
'Consumer',
|
|
16
|
+
'ConsumersRegistry',
|
|
17
|
+
'FlaskRMQ',
|
|
18
|
+
'FlaskRMQError',
|
|
19
|
+
'MessageCallback',
|
|
20
|
+
'NotInitializedError',
|
|
21
|
+
'Producer',
|
|
22
|
+
'QueueConfig',
|
|
23
|
+
'SetupRegistry',
|
|
24
|
+
'get_consumers_registry',
|
|
25
|
+
'get_setup_registry',
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
__version__ = '0.1.0'
|
flask_rmq/cli.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import signal
|
|
4
|
+
import threading
|
|
5
|
+
from types import FrameType
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from flask import Flask, current_app
|
|
9
|
+
from flask.cli import with_appcontext
|
|
10
|
+
|
|
11
|
+
from flask_rmq.consumer import Consumer
|
|
12
|
+
from flask_rmq.state import get_state
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _aliases(using: str | None) -> list[str]:
|
|
16
|
+
state = get_state()
|
|
17
|
+
if using is not None:
|
|
18
|
+
if using not in state.connection_managers:
|
|
19
|
+
raise click.ClickException(f'Unknown RabbitMQ alias {using!r}')
|
|
20
|
+
return [using]
|
|
21
|
+
return list(state.connection_managers)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@click.group('rmq')
|
|
25
|
+
def rmq_cli() -> None:
|
|
26
|
+
"""Set up RabbitMQ topology and run registered consumers."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@rmq_cli.command('check')
|
|
30
|
+
@with_appcontext
|
|
31
|
+
def check_command() -> None:
|
|
32
|
+
"""Validate configuration and show registered objects without connecting."""
|
|
33
|
+
|
|
34
|
+
state = get_state()
|
|
35
|
+
for alias in state.connection_managers:
|
|
36
|
+
setups = len(state.setup_registries[alias].all())
|
|
37
|
+
consumers = len(state.consumers_registries[alias].all())
|
|
38
|
+
click.echo(f'{alias}: configuration OK, {setups} setup callback(s), {consumers} consumer(s)')
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@rmq_cli.command('setup')
|
|
42
|
+
@click.option('--using', help='Configure only this RABBITMQ_CONNECTIONS alias.')
|
|
43
|
+
@with_appcontext
|
|
44
|
+
def setup_command(using: str | None) -> None:
|
|
45
|
+
"""Run all registered idempotent topology callbacks."""
|
|
46
|
+
|
|
47
|
+
state = get_state()
|
|
48
|
+
for alias in _aliases(using):
|
|
49
|
+
callbacks = state.setup_registries[alias].all()
|
|
50
|
+
click.echo(f'[{alias}] applying {len(callbacks)} topology callback(s)')
|
|
51
|
+
channel = state.connection_managers[alias].get_producer_channel()
|
|
52
|
+
for callback in callbacks:
|
|
53
|
+
callback(channel)
|
|
54
|
+
click.echo(f' ✓ {callback.__module__}.{callback.__name__}')
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _run_consumer(app: Flask, consumer: Consumer, stop_event: threading.Event) -> None:
|
|
58
|
+
with app.app_context():
|
|
59
|
+
consumer.consume(stop_event=stop_event)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@rmq_cli.command('consume')
|
|
63
|
+
@click.option('--using', help='Start only consumers registered for this alias.')
|
|
64
|
+
@with_appcontext
|
|
65
|
+
def consume_command(using: str | None) -> None:
|
|
66
|
+
"""Run every registered consumer in its own thread until SIGINT/SIGTERM."""
|
|
67
|
+
|
|
68
|
+
state = get_state()
|
|
69
|
+
app = current_app
|
|
70
|
+
pairs = [(alias, consumer) for alias in _aliases(using) for consumer in state.consumers_registries[alias].all()]
|
|
71
|
+
if not pairs:
|
|
72
|
+
click.echo('No consumers registered.')
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
stop_event = threading.Event()
|
|
76
|
+
|
|
77
|
+
def request_stop(_signum: int, _frame: FrameType | None) -> None:
|
|
78
|
+
stop_event.set()
|
|
79
|
+
|
|
80
|
+
previous_sigint = signal.signal(signal.SIGINT, request_stop)
|
|
81
|
+
previous_sigterm = signal.signal(signal.SIGTERM, request_stop)
|
|
82
|
+
|
|
83
|
+
threads = []
|
|
84
|
+
try:
|
|
85
|
+
for alias, consumer in pairs:
|
|
86
|
+
thread = threading.Thread(
|
|
87
|
+
target=_run_consumer,
|
|
88
|
+
args=(app, consumer, stop_event),
|
|
89
|
+
name=f'flask-rmq-{alias}-{consumer.queue}',
|
|
90
|
+
daemon=False,
|
|
91
|
+
)
|
|
92
|
+
thread.start()
|
|
93
|
+
threads.append(thread)
|
|
94
|
+
click.echo(f'[{alias}] started {consumer.queue} → {consumer.handler_name}')
|
|
95
|
+
for thread in threads:
|
|
96
|
+
while thread.is_alive():
|
|
97
|
+
thread.join(timeout=0.5)
|
|
98
|
+
except KeyboardInterrupt:
|
|
99
|
+
stop_event.set()
|
|
100
|
+
finally:
|
|
101
|
+
stop_event.set()
|
|
102
|
+
for thread in threads:
|
|
103
|
+
thread.join(timeout=5)
|
|
104
|
+
signal.signal(signal.SIGINT, previous_sigint)
|
|
105
|
+
signal.signal(signal.SIGTERM, previous_sigterm)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main() -> None:
|
|
109
|
+
"""Explain the app-bound entry point when invoked as ``flask-rmq``."""
|
|
110
|
+
|
|
111
|
+
raise click.ClickException('Flask-RMQ commands are app-bound. Run: flask --app your_module rmq --help')
|
flask_rmq/config.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from flask_rmq.exceptions import ConfigurationError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class RabbitMQConfig:
|
|
12
|
+
"""Resolved settings for one independent RabbitMQ broker."""
|
|
13
|
+
|
|
14
|
+
host: str
|
|
15
|
+
port: int = 5672
|
|
16
|
+
virtual_host: str = '/'
|
|
17
|
+
user: str = 'guest'
|
|
18
|
+
password: str = 'guest'
|
|
19
|
+
heartbeat: int = 600
|
|
20
|
+
blocked_connection_timeout: float = 300.0
|
|
21
|
+
reconnect_initial_backoff: float = 1.0
|
|
22
|
+
reconnect_max_backoff: float = 30.0
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_mapping(cls, alias: str, values: Mapping[str, Any]) -> RabbitMQConfig:
|
|
26
|
+
"""Build and validate a config, accepting Flask-style uppercase keys."""
|
|
27
|
+
|
|
28
|
+
normalized = {str(key).lower(): value for key, value in values.items()}
|
|
29
|
+
if not normalized.get('host'):
|
|
30
|
+
raise ConfigurationError(f'RABBITMQ_CONNECTIONS[{alias!r}] requires HOST')
|
|
31
|
+
try:
|
|
32
|
+
config = cls(
|
|
33
|
+
host=str(normalized['host']),
|
|
34
|
+
port=int(normalized.get('port', 5672)),
|
|
35
|
+
virtual_host=str(normalized.get('virtual_host', '/')),
|
|
36
|
+
user=str(normalized.get('user', 'guest')),
|
|
37
|
+
password=str(normalized.get('password', 'guest')),
|
|
38
|
+
heartbeat=int(normalized.get('heartbeat', 600)),
|
|
39
|
+
blocked_connection_timeout=float(normalized.get('blocked_connection_timeout', 300.0)),
|
|
40
|
+
reconnect_initial_backoff=float(normalized.get('reconnect_initial_backoff', 1.0)),
|
|
41
|
+
reconnect_max_backoff=float(normalized.get('reconnect_max_backoff', 30.0)),
|
|
42
|
+
)
|
|
43
|
+
except (TypeError, ValueError) as exc:
|
|
44
|
+
raise ConfigurationError(f'Invalid RabbitMQ values for alias {alias!r}: {exc}') from exc
|
|
45
|
+
if not 1 <= config.port <= 65535:
|
|
46
|
+
raise ConfigurationError(f'RabbitMQ PORT for {alias!r} must be between 1 and 65535')
|
|
47
|
+
if config.heartbeat < 0 or config.blocked_connection_timeout <= 0:
|
|
48
|
+
raise ConfigurationError(f'RabbitMQ timeouts for {alias!r} must be positive')
|
|
49
|
+
if config.reconnect_initial_backoff <= 0 or config.reconnect_max_backoff <= 0:
|
|
50
|
+
raise ConfigurationError(f'RabbitMQ reconnect backoffs for {alias!r} must be positive')
|
|
51
|
+
if config.reconnect_initial_backoff > config.reconnect_max_backoff:
|
|
52
|
+
raise ConfigurationError(
|
|
53
|
+
f'RabbitMQ RECONNECT_INITIAL_BACKOFF for {alias!r} cannot exceed RECONNECT_MAX_BACKOFF'
|
|
54
|
+
)
|
|
55
|
+
return config
|
flask_rmq/connections.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
from pika import BlockingConnection, ConnectionParameters, PlainCredentials
|
|
7
|
+
from pika.adapters.blocking_connection import BlockingChannel
|
|
8
|
+
from pika.exceptions import AMQPError
|
|
9
|
+
|
|
10
|
+
from flask_rmq.config import RabbitMQConfig
|
|
11
|
+
from flask_rmq.state import get_state, resolve_alias
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger('rabbitmq')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_connection_manager(using: str | None = None) -> RabbitMQConnectionManager:
|
|
17
|
+
return resolve_alias(get_state().connection_managers, using)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RabbitMQConnectionManager:
|
|
21
|
+
"""Own thread-local, role-separated Pika connections for one broker alias."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, config: RabbitMQConfig) -> None:
|
|
24
|
+
self.config = config
|
|
25
|
+
self._parameters = ConnectionParameters(
|
|
26
|
+
host=config.host,
|
|
27
|
+
port=config.port,
|
|
28
|
+
virtual_host=config.virtual_host,
|
|
29
|
+
credentials=PlainCredentials(config.user, config.password),
|
|
30
|
+
heartbeat=config.heartbeat,
|
|
31
|
+
blocked_connection_timeout=config.blocked_connection_timeout,
|
|
32
|
+
)
|
|
33
|
+
self._local = threading.local()
|
|
34
|
+
|
|
35
|
+
def _connection(self, attr: str) -> BlockingConnection:
|
|
36
|
+
connection = getattr(self._local, attr, None)
|
|
37
|
+
if connection is None or not connection.is_open:
|
|
38
|
+
connection = BlockingConnection(self._parameters)
|
|
39
|
+
setattr(self._local, attr, connection)
|
|
40
|
+
return connection
|
|
41
|
+
|
|
42
|
+
def get_producer_connection(self) -> BlockingConnection:
|
|
43
|
+
return self._connection('producer_connection')
|
|
44
|
+
|
|
45
|
+
def get_consumer_connection(self) -> BlockingConnection:
|
|
46
|
+
return self._connection('consumer_connection')
|
|
47
|
+
|
|
48
|
+
def get_producer_channel(self) -> BlockingChannel:
|
|
49
|
+
connection = self.get_producer_connection()
|
|
50
|
+
channel = getattr(self._local, 'producer_channel', None)
|
|
51
|
+
if channel is None or not channel.is_open:
|
|
52
|
+
channel = connection.channel()
|
|
53
|
+
channel.confirm_delivery()
|
|
54
|
+
self._local.producer_channel = channel
|
|
55
|
+
return channel
|
|
56
|
+
|
|
57
|
+
def reset_producer_channel(self) -> None:
|
|
58
|
+
"""Close and forget producer resources after a transport failure."""
|
|
59
|
+
|
|
60
|
+
for attr in ('producer_channel', 'producer_connection'):
|
|
61
|
+
resource = getattr(self._local, attr, None)
|
|
62
|
+
if resource is None:
|
|
63
|
+
continue
|
|
64
|
+
try:
|
|
65
|
+
if resource.is_open:
|
|
66
|
+
resource.close()
|
|
67
|
+
except (AMQPError, OSError):
|
|
68
|
+
logger.warning('Failed to close stale RabbitMQ %s', attr, exc_info=True)
|
|
69
|
+
finally:
|
|
70
|
+
delattr(self._local, attr)
|
|
71
|
+
|
|
72
|
+
def close_thread_connections(self) -> None:
|
|
73
|
+
"""Close all resources owned by the calling thread."""
|
|
74
|
+
|
|
75
|
+
self.reset_producer_channel()
|
|
76
|
+
connection = getattr(self._local, 'consumer_connection', None)
|
|
77
|
+
if connection is not None:
|
|
78
|
+
try:
|
|
79
|
+
if connection.is_open:
|
|
80
|
+
connection.close()
|
|
81
|
+
except (AMQPError, OSError):
|
|
82
|
+
logger.warning('Failed to close consumer connection', exc_info=True)
|
|
83
|
+
finally:
|
|
84
|
+
delattr(self._local, 'consumer_connection')
|
flask_rmq/consumer.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from pika.exceptions import (
|
|
10
|
+
AMQPConnectionError,
|
|
11
|
+
ChannelClosed,
|
|
12
|
+
ChannelClosedByBroker,
|
|
13
|
+
ConnectionClosed,
|
|
14
|
+
StreamLostError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from flask_rmq.connections import get_connection_manager
|
|
18
|
+
from flask_rmq.queues import QueueConfig
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from pika.adapters.blocking_connection import BlockingChannel, BlockingConnection
|
|
22
|
+
from pika.spec import Basic, BasicProperties
|
|
23
|
+
|
|
24
|
+
MessageCallback = Callable[['BlockingChannel', 'Basic.Deliver', 'BasicProperties', bytes], None]
|
|
25
|
+
logger = logging.getLogger('rabbitmq')
|
|
26
|
+
_RECONNECTABLE_ERRORS = (
|
|
27
|
+
AMQPConnectionError,
|
|
28
|
+
ConnectionClosed,
|
|
29
|
+
ChannelClosed,
|
|
30
|
+
ChannelClosedByBroker,
|
|
31
|
+
StreamLostError,
|
|
32
|
+
ConnectionResetError,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Consumer:
|
|
37
|
+
"""Consume one queue with reconnect backoff and graceful shutdown."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
queue: QueueConfig | str,
|
|
42
|
+
prefetch_count: int = 1,
|
|
43
|
+
reconnect_initial_backoff: float | None = None,
|
|
44
|
+
reconnect_max_backoff: float | None = None,
|
|
45
|
+
using: str | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
if prefetch_count < 1:
|
|
48
|
+
raise ValueError('prefetch_count must be at least 1')
|
|
49
|
+
self.queue = str(queue)
|
|
50
|
+
self._queue_config = queue
|
|
51
|
+
self._prefetch_count = prefetch_count
|
|
52
|
+
self._using = using
|
|
53
|
+
self._initial_override = reconnect_initial_backoff
|
|
54
|
+
self._max_override = reconnect_max_backoff
|
|
55
|
+
self._handler: MessageCallback | None = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def prefetch_count(self) -> int:
|
|
59
|
+
return self._prefetch_count
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def using(self) -> str | None:
|
|
63
|
+
return self._using
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def handler_name(self) -> str:
|
|
67
|
+
return self._handler.__name__ if self._handler else 'unregistered'
|
|
68
|
+
|
|
69
|
+
def handler(self, func: MessageCallback) -> MessageCallback:
|
|
70
|
+
if self._handler is not None:
|
|
71
|
+
raise RuntimeError(f'Consumer for queue {self.queue!r} already has a handler')
|
|
72
|
+
self._handler = func
|
|
73
|
+
return func
|
|
74
|
+
|
|
75
|
+
def __call__(self, func: MessageCallback) -> MessageCallback:
|
|
76
|
+
return self.handler(func)
|
|
77
|
+
|
|
78
|
+
def consume(self, stop_event: threading.Event | None = None) -> None:
|
|
79
|
+
if self._handler is None:
|
|
80
|
+
raise RuntimeError(f'Consumer for queue {self.queue!r} has no handler')
|
|
81
|
+
stop_event = stop_event or threading.Event()
|
|
82
|
+
config = get_connection_manager(self._using).config
|
|
83
|
+
initial = self._initial_override if self._initial_override is not None else config.reconnect_initial_backoff
|
|
84
|
+
maximum = self._max_override if self._max_override is not None else config.reconnect_max_backoff
|
|
85
|
+
backoff = initial
|
|
86
|
+
while not stop_event.is_set():
|
|
87
|
+
try:
|
|
88
|
+
self._run_session(self._handler, stop_event)
|
|
89
|
+
return
|
|
90
|
+
except _RECONNECTABLE_ERRORS:
|
|
91
|
+
logger.warning(
|
|
92
|
+
'RabbitMQ consumer %s disconnected; retrying in %.2fs',
|
|
93
|
+
self.queue,
|
|
94
|
+
backoff,
|
|
95
|
+
exc_info=True,
|
|
96
|
+
)
|
|
97
|
+
stop_event.wait(backoff)
|
|
98
|
+
backoff = min(backoff * 2, maximum)
|
|
99
|
+
|
|
100
|
+
def _run_session(self, handler: MessageCallback, stop_event: threading.Event) -> None:
|
|
101
|
+
connection: BlockingConnection = get_connection_manager(self._using).get_consumer_connection()
|
|
102
|
+
channel: BlockingChannel = connection.channel()
|
|
103
|
+
try:
|
|
104
|
+
self._declare_queue(channel)
|
|
105
|
+
channel.basic_qos(prefetch_count=self._prefetch_count)
|
|
106
|
+
channel.basic_consume(queue=self.queue, on_message_callback=functools.partial(self._dispatch, handler))
|
|
107
|
+
logger.info('Consuming queue %s with %s', self.queue, handler.__name__)
|
|
108
|
+
while not stop_event.is_set():
|
|
109
|
+
connection.process_data_events(time_limit=1)
|
|
110
|
+
finally:
|
|
111
|
+
if channel.is_open:
|
|
112
|
+
channel.stop_consuming()
|
|
113
|
+
channel.close()
|
|
114
|
+
|
|
115
|
+
def _declare_queue(self, channel: BlockingChannel) -> None:
|
|
116
|
+
if isinstance(self._queue_config, QueueConfig):
|
|
117
|
+
channel.queue_declare(
|
|
118
|
+
queue=self.queue,
|
|
119
|
+
durable=self._queue_config.durable,
|
|
120
|
+
arguments=self._queue_config.arguments,
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
channel.queue_declare(queue=self.queue, durable=True)
|
|
124
|
+
|
|
125
|
+
def _dispatch(
|
|
126
|
+
self,
|
|
127
|
+
handler: MessageCallback,
|
|
128
|
+
ch: BlockingChannel,
|
|
129
|
+
method: Basic.Deliver,
|
|
130
|
+
props: BasicProperties,
|
|
131
|
+
body: bytes,
|
|
132
|
+
) -> None:
|
|
133
|
+
try:
|
|
134
|
+
handler(ch, method, props, body)
|
|
135
|
+
except Exception:
|
|
136
|
+
logger.exception('Handler %s failed for queue %s; nacking', handler.__name__, self.queue)
|
|
137
|
+
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False)
|
flask_rmq/exceptions.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class FlaskRMQError(Exception):
|
|
2
|
+
"""Base exception raised by Flask-RMQ."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConfigurationError(FlaskRMQError):
|
|
6
|
+
"""Raised when Flask-RMQ configuration is missing or invalid."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NotInitializedError(FlaskRMQError):
|
|
10
|
+
"""Raised when an API is used before :class:`FlaskRMQ` initialization."""
|
flask_rmq/extension.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
from flask import Flask
|
|
7
|
+
|
|
8
|
+
from flask_rmq.config import RabbitMQConfig
|
|
9
|
+
from flask_rmq.connections import RabbitMQConnectionManager
|
|
10
|
+
from flask_rmq.exceptions import ConfigurationError
|
|
11
|
+
from flask_rmq.registries import ConsumersRegistry, SetupRegistry
|
|
12
|
+
from flask_rmq.state import RMQState, register_state
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FlaskRMQ:
|
|
16
|
+
"""Flask extension that configures RabbitMQ aliases and Click commands."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, app: Flask | None = None) -> None:
|
|
19
|
+
if app is not None:
|
|
20
|
+
self.init_app(app)
|
|
21
|
+
|
|
22
|
+
def init_app(self, app: Flask) -> None:
|
|
23
|
+
if 'flask-rmq' in app.extensions:
|
|
24
|
+
raise ConfigurationError('Flask-RMQ is already initialized for this app')
|
|
25
|
+
raw_connections: Any = app.config.get('RABBITMQ_CONNECTIONS')
|
|
26
|
+
if not isinstance(raw_connections, Mapping) or not raw_connections:
|
|
27
|
+
raise ConfigurationError('Flask-RMQ requires a non-empty RABBITMQ_CONNECTIONS mapping in app.config')
|
|
28
|
+
|
|
29
|
+
managers: dict[str, RabbitMQConnectionManager] = {}
|
|
30
|
+
setups: dict[str, SetupRegistry] = {}
|
|
31
|
+
consumers: dict[str, ConsumersRegistry] = {}
|
|
32
|
+
for raw_alias, values in raw_connections.items():
|
|
33
|
+
alias = str(raw_alias)
|
|
34
|
+
if not isinstance(values, Mapping):
|
|
35
|
+
raise ConfigurationError(f'RABBITMQ_CONNECTIONS[{alias!r}] must be a mapping')
|
|
36
|
+
managers[alias] = RabbitMQConnectionManager(RabbitMQConfig.from_mapping(alias, values))
|
|
37
|
+
setups[alias] = SetupRegistry()
|
|
38
|
+
consumers[alias] = ConsumersRegistry()
|
|
39
|
+
|
|
40
|
+
state = RMQState(managers, setups, consumers)
|
|
41
|
+
app.extensions['flask-rmq'] = state
|
|
42
|
+
register_state(app, state)
|
|
43
|
+
|
|
44
|
+
from .cli import rmq_cli
|
|
45
|
+
|
|
46
|
+
app.cli.add_command(rmq_cli)
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def get_state(app: Flask) -> RMQState:
|
|
50
|
+
try:
|
|
51
|
+
return cast(RMQState, app.extensions['flask-rmq'])
|
|
52
|
+
except KeyError as exc:
|
|
53
|
+
raise ConfigurationError('Flask-RMQ is not initialized for this app') from exc
|