ergon-framework-python 0.2.2__py3-none-any.whl → 0.2.4__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.
@@ -0,0 +1,410 @@
1
+ import base64
2
+ import logging
3
+ from pathlib import Path
4
+ from typing import Any, Dict, List, Optional
5
+ from urllib.parse import quote
6
+
7
+ import requests
8
+
9
+ from ..transaction import Transaction
10
+ from .models import (
11
+ OutlookEmailAddress,
12
+ OutlookFlagStatus,
13
+ OutlookGraphClient,
14
+ OutlookMessageQuery,
15
+ OutlookProducerConfig,
16
+ OutlookSendMessagePayload,
17
+ OutlookWellKnownFolder,
18
+ )
19
+ from .utils import message_to_transaction, normalize_send_payload
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _OUTLOOK_IMPORT_ERROR = "Install with: pip install ergon-framework-python[outlook]"
24
+
25
+
26
+ def _graph_error_detail(response: requests.Response) -> str:
27
+ try:
28
+ payload = response.json()
29
+ except ValueError:
30
+ return (response.text or response.reason or "").strip()
31
+ error = payload.get("error") if isinstance(payload, dict) else None
32
+ if isinstance(error, dict):
33
+ message = error.get("message") or error.get("code")
34
+ if message:
35
+ return str(message)
36
+ return (response.reason or "").strip()
37
+
38
+
39
+ def _permission_hint(method: str, path: str) -> str:
40
+ target = f"{method} {path}".casefold()
41
+ if any(token in target for token in ("/sendmail", "/reply", "/replyall", "/forward")):
42
+ return "This operation requires the Mail.Send application permission."
43
+ if method.upper() in {"PATCH", "DELETE"} or "/move" in target:
44
+ return "This operation requires the Mail.ReadWrite application permission."
45
+ return "This operation requires the Mail.Read application permission."
46
+
47
+
48
+ def _raise_graph_error(method: str, path: str, response: requests.Response) -> None:
49
+ if response.status_code == 403:
50
+ detail = _graph_error_detail(response)
51
+ suffix = f" Graph: {detail}" if detail else ""
52
+ raise requests.HTTPError(
53
+ f"403 Forbidden from Microsoft Graph. {_permission_hint(method, path)}{suffix}",
54
+ response=response,
55
+ )
56
+ response.raise_for_status()
57
+
58
+
59
+ def _get_msal_app_class():
60
+ try:
61
+ from msal import ConfidentialClientApplication
62
+ except ImportError as exc:
63
+ raise ImportError(_OUTLOOK_IMPORT_ERROR) from exc
64
+ return ConfidentialClientApplication
65
+
66
+
67
+ class OutlookGraphService:
68
+ """Low-level Microsoft Graph mail service using application credentials."""
69
+
70
+ def __init__(
71
+ self,
72
+ client: OutlookGraphClient,
73
+ session: Optional[requests.Session] = None,
74
+ ) -> None:
75
+ self.client = client
76
+ self._session = session or requests.Session()
77
+ self._msal_app: Any = None
78
+ self._access_token: Optional[str] = None
79
+ self._next_link: Optional[str] = None
80
+ self._exhausted = False
81
+
82
+ @property
83
+ def user_email(self) -> str:
84
+ return self.client.user_email
85
+
86
+ def close(self) -> None:
87
+ self._session.close()
88
+
89
+ def reset_pagination(self) -> None:
90
+ self._next_link = None
91
+ self._exhausted = False
92
+
93
+ def _authenticate(self, *, force: bool = False) -> str:
94
+ if self._access_token and not force:
95
+ return self._access_token
96
+ if self._msal_app is None or force:
97
+ app_class = _get_msal_app_class()
98
+ self._msal_app = app_class(
99
+ client_id=self.client.client_id,
100
+ client_credential=self.client.client_secret,
101
+ authority=self.client.authority,
102
+ )
103
+ token_response = self._msal_app.acquire_token_for_client(scopes=self.client.scopes)
104
+ access_token = token_response.get("access_token")
105
+ if not isinstance(access_token, str) or not access_token:
106
+ description = token_response.get("error_description") or token_response.get("error") or "unknown error"
107
+ raise RuntimeError(f"Microsoft Graph authentication failed: {description}")
108
+ self._access_token = access_token
109
+ return access_token
110
+
111
+ def _headers(self, *, eventual_consistency: bool = False) -> Dict[str, str]:
112
+ headers = {
113
+ "Authorization": f"Bearer {self._authenticate()}",
114
+ "Accept": "application/json",
115
+ "Content-Type": "application/json",
116
+ }
117
+ if eventual_consistency:
118
+ headers["ConsistencyLevel"] = "eventual"
119
+ return headers
120
+
121
+ def _url(self, path: str) -> str:
122
+ if path.startswith("http://") or path.startswith("https://"):
123
+ base = self.client.graph_base_url.rstrip("/")
124
+ if path != base and not path.startswith(f"{base}/"):
125
+ raise ValueError("Microsoft Graph pagination URL is outside graph_base_url")
126
+ return path
127
+ return f"{self.client.graph_base_url.rstrip('/')}/{path.lstrip('/')}"
128
+
129
+ def _request(
130
+ self,
131
+ method: str,
132
+ path: str,
133
+ params: Optional[Dict[str, Any]] = None,
134
+ json: Optional[Dict[str, Any]] = None,
135
+ eventual_consistency: bool = False,
136
+ ) -> requests.Response:
137
+ response = self._session.request(
138
+ method,
139
+ self._url(path),
140
+ headers=self._headers(eventual_consistency=eventual_consistency),
141
+ params=params,
142
+ json=json,
143
+ timeout=self.client.timeout_sec,
144
+ )
145
+ if response.status_code == 401:
146
+ self._authenticate(force=True)
147
+ response = self._session.request(
148
+ method,
149
+ self._url(path),
150
+ headers=self._headers(eventual_consistency=eventual_consistency),
151
+ params=params,
152
+ json=json,
153
+ timeout=self.client.timeout_sec,
154
+ )
155
+ if response.status_code >= 400:
156
+ _raise_graph_error(method, path, response)
157
+ return response
158
+
159
+ def _user_path(self) -> str:
160
+ return f"users/{quote(self.user_email, safe='')}"
161
+
162
+ @staticmethod
163
+ def _response_data(response: requests.Response) -> Dict[str, Any]:
164
+ if response.status_code == 204 or not response.content:
165
+ return {}
166
+ data = response.json()
167
+ return data if isinstance(data, dict) else {}
168
+
169
+ def list_messages(
170
+ self,
171
+ query: OutlookMessageQuery,
172
+ limit: int,
173
+ next_link: Optional[str] = None,
174
+ use_internal_pagination: bool = True,
175
+ ) -> Dict[str, Any]:
176
+ if use_internal_pagination and self._exhausted:
177
+ return {"data": [], "next_link": None}
178
+
179
+ cursor = next_link if next_link is not None else (self._next_link if use_internal_pagination else None)
180
+ if cursor:
181
+ response = self._request("GET", cursor, eventual_consistency=query.requires_eventual_consistency)
182
+ else:
183
+ folder = quote(query.folder_id, safe="")
184
+ response = self._request(
185
+ "GET",
186
+ f"{self._user_path()}/mailFolders/{folder}/messages",
187
+ params=query.to_query_params(top=limit),
188
+ eventual_consistency=query.requires_eventual_consistency,
189
+ )
190
+ body = self._response_data(response)
191
+ messages = body.get("value") or []
192
+ if not isinstance(messages, list):
193
+ messages = []
194
+ cursor = body.get("@odata.nextLink")
195
+ cursor = cursor if isinstance(cursor, str) and cursor else None
196
+ if use_internal_pagination:
197
+ self._next_link = cursor
198
+ self._exhausted = cursor is None
199
+ return {"data": messages, "next_link": cursor}
200
+
201
+ def find_message(self, message_id: str, *, select: Optional[List[str]] = None) -> Dict[str, Any]:
202
+ params = {"$select": ",".join(select)} if select else None
203
+ response = self._request(
204
+ "GET",
205
+ f"{self._user_path()}/messages/{quote(message_id, safe='')}",
206
+ params=params,
207
+ )
208
+ return self._response_data(response)
209
+
210
+ def list_attachments(self, message_id: str, *, download_content: bool = False) -> List[Dict[str, Any]]:
211
+ path: Optional[str] = f"{self._user_path()}/messages/{quote(message_id, safe='')}/attachments"
212
+ attachments: List[Dict[str, Any]] = []
213
+ while path:
214
+ response = self._request("GET", path)
215
+ body = self._response_data(response)
216
+ for item in body.get("value") or []:
217
+ if not isinstance(item, dict):
218
+ continue
219
+ if item.get("@odata.type") != "#microsoft.graph.fileAttachment":
220
+ continue
221
+ attachment = {
222
+ "id": item.get("id"),
223
+ "name": item.get("name"),
224
+ "content_type": item.get("contentType"),
225
+ "size": item.get("size"),
226
+ "is_inline": item.get("isInline", False),
227
+ }
228
+ if download_content and item.get("contentBytes"):
229
+ attachment["content"] = base64.b64decode(item["contentBytes"])
230
+ attachments.append(attachment)
231
+ next_link = body.get("@odata.nextLink")
232
+ path = next_link if isinstance(next_link, str) and next_link else None
233
+ return attachments
234
+
235
+ def save_attachment(
236
+ self,
237
+ attachment: Dict[str, Any],
238
+ destination: str | Path,
239
+ overwrite: bool = False,
240
+ ) -> Path:
241
+ name = Path(str(attachment.get("name") or "")).name
242
+ content = attachment.get("content")
243
+ if not name or not isinstance(content, bytes):
244
+ raise ValueError("Attachment requires a safe name and downloaded byte content")
245
+
246
+ directory = Path(destination)
247
+ directory.mkdir(parents=True, exist_ok=True)
248
+ path = directory / name
249
+ if path.exists() and not overwrite:
250
+ stem, suffix = path.stem, path.suffix
251
+ counter = 1
252
+ while path.exists():
253
+ path = directory / f"{stem}_{counter}{suffix}"
254
+ counter += 1
255
+ path.write_bytes(content)
256
+ return path
257
+
258
+ def fetch_items(
259
+ self,
260
+ query: OutlookMessageQuery,
261
+ limit: int,
262
+ download_attachments: bool = False,
263
+ ) -> List[Transaction]:
264
+ result = self.list_messages(query, limit)
265
+ transactions: List[Transaction] = []
266
+ for message in result["data"]:
267
+ attachments: List[Dict[str, Any]] = []
268
+ if message.get("hasAttachments"):
269
+ attachments = self.list_attachments(
270
+ str(message.get("id", "")),
271
+ download_content=download_attachments,
272
+ )
273
+ transactions.append(message_to_transaction(message, self.user_email, attachments))
274
+ return transactions
275
+
276
+ def find_message_transaction(
277
+ self,
278
+ message_id: str,
279
+ select: Optional[List[str]] = None,
280
+ download_attachments: bool = False,
281
+ ) -> Transaction:
282
+ message = self.find_message(message_id, select=select)
283
+ attachments: List[Dict[str, Any]] = []
284
+ if message.get("hasAttachments"):
285
+ attachments = self.list_attachments(message_id, download_content=download_attachments)
286
+ return message_to_transaction(message, self.user_email, attachments)
287
+
288
+ def get_messages_count(self, query: OutlookMessageQuery, *, max_pages: Optional[int] = None) -> int:
289
+ count = 0
290
+ pages = 0
291
+ next_link: Optional[str] = None
292
+ while True:
293
+ result = self.list_messages(
294
+ query,
295
+ limit=1000,
296
+ next_link=next_link,
297
+ use_internal_pagination=False,
298
+ )
299
+ batch = result["data"]
300
+ count += len(batch)
301
+ pages += 1
302
+ next_link = result["next_link"]
303
+ if not batch or not next_link:
304
+ break
305
+ if max_pages is not None and pages >= max_pages:
306
+ break
307
+ return count
308
+
309
+ def send_message(
310
+ self,
311
+ payload: OutlookSendMessagePayload,
312
+ producer_config: Optional[OutlookProducerConfig] = None,
313
+ ) -> None:
314
+ config = producer_config or OutlookProducerConfig()
315
+ self._request(
316
+ "POST",
317
+ f"{self._user_path()}/sendMail",
318
+ json={
319
+ "message": normalize_send_payload(payload),
320
+ "saveToSentItems": config.save_to_sent_items,
321
+ },
322
+ )
323
+
324
+ def update_message(self, message_id: str, request_body: Dict[str, Any]) -> Dict[str, Any]:
325
+ response = self._request(
326
+ "PATCH",
327
+ f"{self._user_path()}/messages/{quote(message_id, safe='')}",
328
+ json=request_body,
329
+ )
330
+ return self._response_data(response)
331
+
332
+ def mark_as_read(self, message_id: str) -> Dict[str, Any]:
333
+ return self.update_message(message_id, {"isRead": True})
334
+
335
+ def mark_as_unread(self, message_id: str) -> Dict[str, Any]:
336
+ return self.update_message(message_id, {"isRead": False})
337
+
338
+ def set_flag(
339
+ self,
340
+ message_id: str,
341
+ status: OutlookFlagStatus | str = OutlookFlagStatus.FLAGGED,
342
+ ) -> Dict[str, Any]:
343
+ flag_status = OutlookFlagStatus(status)
344
+ return self.update_message(message_id, {"flag": {"flagStatus": flag_status.value}})
345
+
346
+ def set_categories(self, message_id: str, categories: List[str]) -> Dict[str, Any]:
347
+ normalized = list(dict.fromkeys(category.strip() for category in categories if category.strip()))
348
+ return self.update_message(message_id, {"categories": normalized})
349
+
350
+ def move_message(self, message_id: str, folder_id: OutlookWellKnownFolder | str) -> Dict[str, Any]:
351
+ destination = folder_id.value if isinstance(folder_id, OutlookWellKnownFolder) else folder_id
352
+ response = self._request(
353
+ "POST",
354
+ f"{self._user_path()}/messages/{quote(message_id, safe='')}/move",
355
+ json={"destinationId": destination},
356
+ )
357
+ return self._response_data(response)
358
+
359
+ def delete_message(self, message_id: str) -> None:
360
+ self._request(
361
+ "DELETE",
362
+ f"{self._user_path()}/messages/{quote(message_id, safe='')}",
363
+ )
364
+
365
+ def reply(self, message_id: str, *, comment: str = "") -> None:
366
+ self._request(
367
+ "POST",
368
+ f"{self._user_path()}/messages/{quote(message_id, safe='')}/reply",
369
+ json={"comment": comment},
370
+ )
371
+
372
+ def reply_all(self, message_id: str, *, comment: str = "") -> None:
373
+ self._request(
374
+ "POST",
375
+ f"{self._user_path()}/messages/{quote(message_id, safe='')}/replyAll",
376
+ json={"comment": comment},
377
+ )
378
+
379
+ def forward(
380
+ self,
381
+ message_id: str,
382
+ to: List[OutlookEmailAddress],
383
+ comment: str = "",
384
+ ) -> None:
385
+ self._request(
386
+ "POST",
387
+ f"{self._user_path()}/messages/{quote(message_id, safe='')}/forward",
388
+ json={
389
+ "comment": comment,
390
+ "toRecipients": [address.to_graph() for address in to],
391
+ },
392
+ )
393
+
394
+ def list_mail_folders(self, *, include_hidden: bool = False) -> List[Dict[str, Any]]:
395
+ response = self._request(
396
+ "GET",
397
+ f"{self._user_path()}/mailFolders",
398
+ params={"includeHiddenFolders": str(include_hidden).lower()},
399
+ )
400
+ body = self._response_data(response)
401
+ folders = body.get("value") or []
402
+ return folders if isinstance(folders, list) else []
403
+
404
+ def get_mail_folder(self, folder_id: OutlookWellKnownFolder | str) -> Dict[str, Any]:
405
+ value = folder_id.value if isinstance(folder_id, OutlookWellKnownFolder) else folder_id
406
+ response = self._request(
407
+ "GET",
408
+ f"{self._user_path()}/mailFolders/{quote(value, safe='')}",
409
+ )
410
+ return self._response_data(response)
@@ -0,0 +1,118 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from ..transaction import Transaction
4
+ from .models import (
5
+ OutlookAckActionConfig,
6
+ OutlookConsumerConfig,
7
+ OutlookMessageQuery,
8
+ OutlookNackActionConfig,
9
+ OutlookSendMessageInput,
10
+ OutlookSendMessagePayload,
11
+ )
12
+
13
+
14
+ def merge_query(
15
+ base: Optional[OutlookConsumerConfig],
16
+ overrides: Optional[OutlookMessageQuery] = None,
17
+ **kwargs: Any,
18
+ ) -> OutlookMessageQuery:
19
+ query_fields = set(OutlookMessageQuery.model_fields)
20
+ merged: Dict[str, Any] = {}
21
+ if base is not None:
22
+ merged.update({key: value for key, value in base.model_dump(exclude_none=True).items() if key in query_fields})
23
+ if overrides is not None:
24
+ dumped = {
25
+ key: value
26
+ for key, value in overrides.model_dump(exclude_none=True, exclude_unset=True).items()
27
+ if key in query_fields
28
+ }
29
+ if "search" in dumped:
30
+ merged.pop("filter", None)
31
+ if "filter" in dumped:
32
+ merged.pop("search", None)
33
+ merged.update(dumped)
34
+ for key, value in kwargs.items():
35
+ if key not in query_fields or value is None:
36
+ continue
37
+ if key == "search":
38
+ merged.pop("filter", None)
39
+ if key == "filter":
40
+ merged.pop("search", None)
41
+ merged[key] = value
42
+ return OutlookMessageQuery.model_validate(merged)
43
+
44
+
45
+ def _graph_email(value: Any) -> Optional[str]:
46
+ if not isinstance(value, dict):
47
+ return None
48
+ address = value.get("emailAddress")
49
+ if not isinstance(address, dict):
50
+ return None
51
+ email = address.get("address")
52
+ return email if isinstance(email, str) and email else None
53
+
54
+
55
+ def _graph_emails(values: Any) -> list[str]:
56
+ if not isinstance(values, list):
57
+ return []
58
+ emails: list[str] = []
59
+ for item in values:
60
+ email = _graph_email(item)
61
+ if email:
62
+ emails.append(email)
63
+ return emails
64
+
65
+
66
+ def message_to_transaction(
67
+ message: Dict[str, Any],
68
+ user_email: str,
69
+ attachments: Optional[list[Dict[str, Any]]] = None,
70
+ ) -> Transaction:
71
+ attachment_items = attachments or message.get("attachments") or []
72
+ raw_body = message.get("body")
73
+ body = raw_body if isinstance(raw_body, dict) else {}
74
+ return Transaction(
75
+ id=str(message.get("id", "")),
76
+ payload=message,
77
+ metadata={
78
+ "user_email": user_email,
79
+ "subject": message.get("subject"),
80
+ "from_email": _graph_email(message.get("from")),
81
+ "to_emails": _graph_emails(message.get("toRecipients")),
82
+ "received_at": message.get("receivedDateTime"),
83
+ "body_preview": message.get("bodyPreview"),
84
+ "body": body.get("content"),
85
+ "body_type": body.get("contentType"),
86
+ "conversation_id": message.get("conversationId"),
87
+ "internet_message_id": message.get("internetMessageId"),
88
+ "parent_folder_id": message.get("parentFolderId"),
89
+ "has_attachment": bool(message.get("hasAttachments") or attachment_items),
90
+ "attachments": attachment_items,
91
+ "is_read": message.get("isRead"),
92
+ },
93
+ )
94
+
95
+
96
+ def normalize_send_payload(payload: OutlookSendMessagePayload) -> Dict[str, Any]:
97
+ if isinstance(payload, OutlookSendMessageInput):
98
+ return payload.to_graph_message()
99
+ if isinstance(payload, dict):
100
+ if "message" in payload and isinstance(payload["message"], dict):
101
+ return dict(payload["message"])
102
+ return dict(payload)
103
+ raise TypeError(f"Unsupported Outlook dispatch payload type: {type(payload)}")
104
+
105
+
106
+ def build_ack_patch(config: OutlookAckActionConfig) -> Dict[str, Any]:
107
+ return {"isRead": True} if config.mark_as_read else {}
108
+
109
+
110
+ def build_nack_patch(config: OutlookNackActionConfig) -> Dict[str, Any]:
111
+ patch: Dict[str, Any] = {}
112
+ if config.mark_as_unread:
113
+ patch["isRead"] = False
114
+ if config.categories:
115
+ patch["categories"] = list(
116
+ dict.fromkeys(category.strip() for category in config.categories if category.strip())
117
+ )
118
+ return patch
@@ -55,13 +55,14 @@ _DEAD_CHANNEL_EXCEPTIONS: tuple[type[BaseException], ...] = (
55
55
  aio_pika.exceptions.ChannelClosed,
56
56
  )
57
57
 
58
- # Same as above plus ``TimeoutError`` (raised by the async timeout context when an
59
- # ack/nack stalls on a half-open socket). A stalled ack is functionally a dead
60
- # channel: we tear down and let the broker redeliver instead of blocking until
61
- # the heartbeat eventually fires.
58
+ # Same as above plus ``asyncio.TimeoutError`` (raised by the async timeout
59
+ # context when an ack/nack stalls on a half-open socket). A stalled ack is
60
+ # functionally a dead channel: we tear down and let the broker redeliver instead
61
+ # of blocking until the heartbeat eventually fires. Use asyncio's name
62
+ # explicitly because it is distinct from builtins.TimeoutError on Python 3.10.
62
63
  _DEAD_CHANNEL_TIMEOUT_EXCEPTIONS: tuple[type[BaseException], ...] = (
63
64
  *_DEAD_CHANNEL_EXCEPTIONS,
64
- TimeoutError,
65
+ asyncio.TimeoutError,
65
66
  )
66
67
 
67
68
 
@@ -107,6 +108,54 @@ class AsyncRabbitMQService:
107
108
 
108
109
  # ---------- Connection / Channel ----------
109
110
 
111
+ async def _open_owned_robust_connection(
112
+ self,
113
+ url: str,
114
+ kwargs: Dict[str, Any],
115
+ ) -> AbstractRobustConnection:
116
+ """Open a robust connection without losing ownership on startup failure.
117
+
118
+ ``aio_pika.connect_robust`` constructs ``RobustConnection`` and starts
119
+ its background reconnection task before awaiting the initial broker
120
+ connection. If that await times out or is cancelled, the helper never
121
+ returns the connection object, so callers cannot close the reconnect
122
+ task and ``asyncio.run`` can hang while shutting it down.
123
+
124
+ aio-pika exposes ``connection_class`` as its connection factory. Capture
125
+ the object synchronously at that boundary, before its first await, and
126
+ close it on every unsuccessful exit from ``connect_robust``.
127
+ """
128
+ pending_connection: Optional[AbstractRobustConnection] = None
129
+
130
+ def capture_connection(*args: Any, **connection_kwargs: Any) -> AbstractRobustConnection:
131
+ nonlocal pending_connection
132
+ pending_connection = aio_pika.RobustConnection(*args, **connection_kwargs)
133
+ return pending_connection
134
+
135
+ connection_factory = cast(type[AbstractRobustConnection], capture_connection)
136
+ try:
137
+ async with timeout_after(self.client.connect_timeout):
138
+ return await aio_pika.connect_robust( # type: ignore[call-overload]
139
+ url,
140
+ heartbeat=self.client.heartbeat,
141
+ timeout=self.client.connect_timeout,
142
+ connection_class=connection_factory,
143
+ **kwargs,
144
+ )
145
+ except BaseException as exc:
146
+ connection_to_close = pending_connection
147
+ pending_connection = None
148
+ try:
149
+ await self._close_connection_safely(
150
+ connection_to_close,
151
+ f"initial connection attempt failed: {type(exc).__name__}",
152
+ )
153
+ finally:
154
+ # Do not retain the partial connection in this exception's
155
+ # traceback after its reconnect task has been stopped.
156
+ del connection_to_close
157
+ raise
158
+
110
159
  async def _get_connection(self) -> AbstractRobustConnection:
111
160
  async with self._connection_lock:
112
161
  if self._connection is not None and not self._connection.is_closed:
@@ -116,7 +165,7 @@ class AsyncRabbitMQService:
116
165
  try:
117
166
  async with timeout_after(self.client.connect_timeout):
118
167
  await connected.wait()
119
- except TimeoutError:
168
+ except asyncio.TimeoutError:
120
169
  self._consumer_state = "connect_stalled"
121
170
  await self._reset_connection("robust reconnect timed out")
122
171
  raise
@@ -133,14 +182,8 @@ class AsyncRabbitMQService:
133
182
 
134
183
  self._consumer_state = "connecting"
135
184
  try:
136
- async with timeout_after(self.client.connect_timeout):
137
- connection = await aio_pika.connect_robust( # type: ignore[call-overload]
138
- url,
139
- heartbeat=self.client.heartbeat,
140
- timeout=self.client.connect_timeout,
141
- **kwargs,
142
- )
143
- except TimeoutError:
185
+ connection = await self._open_owned_robust_connection(url, kwargs)
186
+ except asyncio.TimeoutError:
144
187
  self._consumer_state = "connect_stalled"
145
188
  raise
146
189
  except BaseException:
@@ -181,7 +224,7 @@ class AsyncRabbitMQService:
181
224
  try:
182
225
  async with timeout_after(close_timeout):
183
226
  await connection.close()
184
- except TimeoutError:
227
+ except asyncio.TimeoutError:
185
228
  logger.error(
186
229
  "Timed out closing RabbitMQ connection after %.1fs (%s)",
187
230
  close_timeout,
@@ -293,7 +336,7 @@ class AsyncRabbitMQService:
293
336
  try:
294
337
  async with timeout_after(self.client.channel_timeout):
295
338
  self._consume_channel = await connection.channel()
296
- except TimeoutError:
339
+ except asyncio.TimeoutError:
297
340
  self._consumer_state = "connect_stalled"
298
341
  await self._reset_connection("consume channel creation timed out")
299
342
  raise
@@ -328,7 +371,7 @@ class AsyncRabbitMQService:
328
371
  try:
329
372
  async with timeout_after(self.client.channel_timeout):
330
373
  self._publish_channel = await connection.channel()
331
- except TimeoutError:
374
+ except asyncio.TimeoutError:
332
375
  await self._reset_connection("publish channel creation timed out")
333
376
  raise
334
377
 
@@ -455,7 +498,7 @@ class AsyncRabbitMQService:
455
498
  break
456
499
  if epoch == self._consumer_epoch and delivery is not None:
457
500
  buffer.append(delivery)
458
- except TimeoutError:
501
+ except asyncio.TimeoutError:
459
502
  poll_completed = True
460
503
  else:
461
504
  poll_completed = True