mcp-eregistrations-bpa 0.8.5__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 mcp-eregistrations-bpa might be problematic. Click here for more details.

Files changed (66) hide show
  1. mcp_eregistrations_bpa/__init__.py +121 -0
  2. mcp_eregistrations_bpa/__main__.py +6 -0
  3. mcp_eregistrations_bpa/arazzo/__init__.py +21 -0
  4. mcp_eregistrations_bpa/arazzo/expression.py +379 -0
  5. mcp_eregistrations_bpa/audit/__init__.py +56 -0
  6. mcp_eregistrations_bpa/audit/context.py +66 -0
  7. mcp_eregistrations_bpa/audit/logger.py +236 -0
  8. mcp_eregistrations_bpa/audit/models.py +131 -0
  9. mcp_eregistrations_bpa/auth/__init__.py +64 -0
  10. mcp_eregistrations_bpa/auth/callback.py +391 -0
  11. mcp_eregistrations_bpa/auth/cas.py +409 -0
  12. mcp_eregistrations_bpa/auth/oidc.py +252 -0
  13. mcp_eregistrations_bpa/auth/permissions.py +162 -0
  14. mcp_eregistrations_bpa/auth/token_manager.py +348 -0
  15. mcp_eregistrations_bpa/bpa_client/__init__.py +84 -0
  16. mcp_eregistrations_bpa/bpa_client/client.py +740 -0
  17. mcp_eregistrations_bpa/bpa_client/endpoints.py +193 -0
  18. mcp_eregistrations_bpa/bpa_client/errors.py +276 -0
  19. mcp_eregistrations_bpa/bpa_client/models.py +203 -0
  20. mcp_eregistrations_bpa/config.py +349 -0
  21. mcp_eregistrations_bpa/db/__init__.py +21 -0
  22. mcp_eregistrations_bpa/db/connection.py +64 -0
  23. mcp_eregistrations_bpa/db/migrations.py +168 -0
  24. mcp_eregistrations_bpa/exceptions.py +39 -0
  25. mcp_eregistrations_bpa/py.typed +0 -0
  26. mcp_eregistrations_bpa/rollback/__init__.py +19 -0
  27. mcp_eregistrations_bpa/rollback/manager.py +616 -0
  28. mcp_eregistrations_bpa/server.py +152 -0
  29. mcp_eregistrations_bpa/tools/__init__.py +372 -0
  30. mcp_eregistrations_bpa/tools/actions.py +155 -0
  31. mcp_eregistrations_bpa/tools/analysis.py +352 -0
  32. mcp_eregistrations_bpa/tools/audit.py +399 -0
  33. mcp_eregistrations_bpa/tools/behaviours.py +1042 -0
  34. mcp_eregistrations_bpa/tools/bots.py +627 -0
  35. mcp_eregistrations_bpa/tools/classifications.py +575 -0
  36. mcp_eregistrations_bpa/tools/costs.py +765 -0
  37. mcp_eregistrations_bpa/tools/debug_strategies.py +351 -0
  38. mcp_eregistrations_bpa/tools/debugger.py +1230 -0
  39. mcp_eregistrations_bpa/tools/determinants.py +2235 -0
  40. mcp_eregistrations_bpa/tools/document_requirements.py +670 -0
  41. mcp_eregistrations_bpa/tools/export.py +899 -0
  42. mcp_eregistrations_bpa/tools/fields.py +162 -0
  43. mcp_eregistrations_bpa/tools/form_errors.py +36 -0
  44. mcp_eregistrations_bpa/tools/formio_helpers.py +971 -0
  45. mcp_eregistrations_bpa/tools/forms.py +1269 -0
  46. mcp_eregistrations_bpa/tools/jsonlogic_builder.py +466 -0
  47. mcp_eregistrations_bpa/tools/large_response.py +163 -0
  48. mcp_eregistrations_bpa/tools/messages.py +523 -0
  49. mcp_eregistrations_bpa/tools/notifications.py +241 -0
  50. mcp_eregistrations_bpa/tools/registration_institutions.py +680 -0
  51. mcp_eregistrations_bpa/tools/registrations.py +897 -0
  52. mcp_eregistrations_bpa/tools/role_status.py +447 -0
  53. mcp_eregistrations_bpa/tools/role_units.py +400 -0
  54. mcp_eregistrations_bpa/tools/roles.py +1236 -0
  55. mcp_eregistrations_bpa/tools/rollback.py +335 -0
  56. mcp_eregistrations_bpa/tools/services.py +674 -0
  57. mcp_eregistrations_bpa/tools/workflows.py +2487 -0
  58. mcp_eregistrations_bpa/tools/yaml_transformer.py +991 -0
  59. mcp_eregistrations_bpa/workflows/__init__.py +28 -0
  60. mcp_eregistrations_bpa/workflows/loader.py +440 -0
  61. mcp_eregistrations_bpa/workflows/models.py +336 -0
  62. mcp_eregistrations_bpa-0.8.5.dist-info/METADATA +965 -0
  63. mcp_eregistrations_bpa-0.8.5.dist-info/RECORD +66 -0
  64. mcp_eregistrations_bpa-0.8.5.dist-info/WHEEL +4 -0
  65. mcp_eregistrations_bpa-0.8.5.dist-info/entry_points.txt +2 -0
  66. mcp_eregistrations_bpa-0.8.5.dist-info/licenses/LICENSE +86 -0
@@ -0,0 +1,740 @@
1
+ """BPA API async HTTP client with retry logic.
2
+
3
+ This module provides the main BPAClient class for interacting with the
4
+ BPA REST API. It features:
5
+
6
+ - Async HTTP operations using httpx
7
+ - Exponential backoff retry for transient errors (429, 502, 503, 504)
8
+ - No retry for client errors (400, 401, 403, 404)
9
+ - Token-based authorization via auth module
10
+ - AI-friendly error translation
11
+
12
+ Usage:
13
+ from mcp_eregistrations_bpa.bpa_client import BPAClient
14
+
15
+ async with BPAClient() as client:
16
+ services = await client.get("/service")
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import logging
23
+ from typing import TYPE_CHECKING, Any, cast
24
+
25
+ import httpx
26
+
27
+ from mcp_eregistrations_bpa.bpa_client.errors import (
28
+ BPAClientError,
29
+ BPAConnectionError,
30
+ BPATimeoutError,
31
+ translate_http_error,
32
+ )
33
+ from mcp_eregistrations_bpa.config import load_config
34
+
35
+ if TYPE_CHECKING:
36
+ from types import TracebackType
37
+
38
+ __all__ = [
39
+ "BPAClient",
40
+ "MAX_RETRIES",
41
+ "BASE_DELAY",
42
+ "MAX_DELAY",
43
+ "DEFAULT_TIMEOUT",
44
+ "RETRYABLE_STATUS_CODES",
45
+ "NON_RETRYABLE_STATUS_CODES",
46
+ ]
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+ # Retry configuration
51
+ MAX_RETRIES = 3
52
+ BASE_DELAY = 1.0 # seconds
53
+ MAX_DELAY = 10.0 # seconds
54
+
55
+ # Timeout configuration (NFR15: 5 second default)
56
+ DEFAULT_TIMEOUT = 5.0 # seconds
57
+
58
+ # Status codes that should trigger retry
59
+ RETRYABLE_STATUS_CODES = frozenset({429, 502, 503, 504})
60
+
61
+ # Status codes that should NOT be retried (client errors)
62
+ NON_RETRYABLE_STATUS_CODES = frozenset({400, 401, 403, 404})
63
+
64
+
65
+ def calculate_backoff_delay(attempt: int) -> float:
66
+ """Calculate exponential backoff delay with jitter.
67
+
68
+ Uses exponential backoff: delay = min(BASE_DELAY * 2^attempt, MAX_DELAY)
69
+ Adds small jitter to prevent thundering herd.
70
+
71
+ Args:
72
+ attempt: The retry attempt number (0-indexed).
73
+
74
+ Returns:
75
+ The delay in seconds before next retry.
76
+ """
77
+ import random
78
+
79
+ delay = min(BASE_DELAY * (2**attempt), MAX_DELAY)
80
+ # Add up to 10% jitter
81
+ jitter = delay * 0.1 * random.random()
82
+ return float(delay + jitter)
83
+
84
+
85
+ class BPAClient:
86
+ """Async HTTP client for BPA API with retry logic.
87
+
88
+ Features:
89
+ - Token-based authorization header injection
90
+ - Exponential backoff retry for transient errors
91
+ - AI-friendly error translation
92
+ - Async context manager support
93
+
94
+ Attributes:
95
+ base_url: The BPA instance base URL.
96
+ timeout: Request timeout in seconds.
97
+ max_retries: Maximum retry attempts for transient errors.
98
+
99
+ Example:
100
+ async with BPAClient() as client:
101
+ # GET request
102
+ services = await client.get("/service")
103
+
104
+ # GET with path parameters
105
+ service = await client.get("/service/{id}", path_params={"id": 123})
106
+
107
+ # POST with body
108
+ result = await client.post("/service", json={"name": "New Service"})
109
+ """
110
+
111
+ def __init__(
112
+ self,
113
+ *,
114
+ base_url: str | None = None,
115
+ timeout: float = DEFAULT_TIMEOUT,
116
+ max_retries: int = MAX_RETRIES,
117
+ ) -> None:
118
+ """Initialize BPA client.
119
+
120
+ Args:
121
+ base_url: BPA instance base URL. If None, uses config.
122
+ timeout: Request timeout in seconds.
123
+ max_retries: Maximum retry attempts for transient errors.
124
+ """
125
+ if base_url is None:
126
+ config = load_config()
127
+ base_url = str(config.bpa_instance_url)
128
+
129
+ # Ensure base URL ends with API path (v2016.06, not /v3)
130
+ if not base_url.endswith("/bparest/bpa/v2016/06"):
131
+ base_url = base_url.rstrip("/") + "/bparest/bpa/v2016/06"
132
+
133
+ self.base_url = base_url
134
+ self.timeout = timeout
135
+ self.max_retries = max_retries
136
+ self._client: httpx.AsyncClient | None = None
137
+
138
+ async def __aenter__(self) -> BPAClient:
139
+ """Enter async context manager."""
140
+ self._client = httpx.AsyncClient(
141
+ base_url=self.base_url,
142
+ timeout=httpx.Timeout(self.timeout),
143
+ )
144
+ return self
145
+
146
+ async def __aexit__(
147
+ self,
148
+ exc_type: type[BaseException] | None,
149
+ exc_val: BaseException | None,
150
+ exc_tb: TracebackType | None,
151
+ ) -> None:
152
+ """Exit async context manager."""
153
+ if self._client:
154
+ await self._client.aclose()
155
+ self._client = None
156
+
157
+ def _get_client(self) -> httpx.AsyncClient:
158
+ """Get the HTTP client, ensuring it's initialized.
159
+
160
+ Returns:
161
+ The httpx async client.
162
+
163
+ Raises:
164
+ RuntimeError: If client is not initialized (use async with).
165
+ """
166
+ if self._client is None:
167
+ msg = "BPAClient must be used as an async context manager"
168
+ raise RuntimeError(msg)
169
+ return self._client
170
+
171
+ async def _get_auth_header(self) -> dict[str, str]:
172
+ """Get authorization header with current access token.
173
+
174
+ Returns:
175
+ Headers dict with Authorization header.
176
+
177
+ Raises:
178
+ ToolError: If not authenticated or token expired.
179
+ """
180
+ from mcp_eregistrations_bpa.auth.permissions import ensure_authenticated
181
+
182
+ token = await ensure_authenticated()
183
+ return {"Authorization": f"Bearer {token}"}
184
+
185
+ def _format_url(self, endpoint: str, path_params: dict[str, Any] | None) -> str:
186
+ """Format URL endpoint with path parameters.
187
+
188
+ Args:
189
+ endpoint: URL endpoint template (e.g., "/service/{id}").
190
+ path_params: Dictionary of path parameters.
191
+
192
+ Returns:
193
+ Formatted URL path.
194
+ """
195
+ if path_params:
196
+ return endpoint.format(**path_params)
197
+ return endpoint
198
+
199
+ async def _request_with_retry(
200
+ self,
201
+ method: str,
202
+ endpoint: str,
203
+ *,
204
+ path_params: dict[str, Any] | None = None,
205
+ params: dict[str, Any] | None = None,
206
+ json: dict[str, Any] | str | None = None,
207
+ content: str | bytes | None = None,
208
+ resource_type: str | None = None,
209
+ resource_id: str | int | None = None,
210
+ ) -> httpx.Response:
211
+ """Execute HTTP request with retry logic.
212
+
213
+ Retries on transient errors (429, 502, 503, 504) with exponential backoff.
214
+ Does NOT retry on client errors (400, 401, 403, 404).
215
+
216
+ Args:
217
+ method: HTTP method (GET, POST, PUT, DELETE).
218
+ endpoint: URL endpoint template.
219
+ path_params: Path parameters for URL formatting.
220
+ params: Query parameters.
221
+ json: JSON body for POST/PUT.
222
+ resource_type: Resource type for error context.
223
+ resource_id: Resource ID for error context.
224
+
225
+ Returns:
226
+ httpx Response object.
227
+
228
+ Raises:
229
+ BPAClientError: On non-retryable errors or after max retries.
230
+ """
231
+ client = self._get_client()
232
+ url = self._format_url(endpoint, path_params)
233
+ headers = await self._get_auth_header()
234
+
235
+ last_error: Exception | None = None
236
+
237
+ for attempt in range(self.max_retries + 1):
238
+ try:
239
+ # Log request details (DEBUG level for verbose output)
240
+ logger.debug(
241
+ "BPA API Request: %s %s%s",
242
+ method,
243
+ self.base_url,
244
+ url,
245
+ )
246
+ if params:
247
+ logger.debug(" Query params: %s", params)
248
+ if json:
249
+ logger.debug(" Request body (json): %s", json)
250
+ if content:
251
+ logger.debug(" Request body (content): %s", content)
252
+
253
+ response = await client.request(
254
+ method,
255
+ url,
256
+ headers=headers,
257
+ params=params,
258
+ json=json,
259
+ content=content,
260
+ )
261
+
262
+ # Log response (INFO for success, DEBUG for body)
263
+ logger.info(
264
+ "BPA API Response: %s %s → %d %s",
265
+ method,
266
+ url,
267
+ response.status_code,
268
+ response.reason_phrase,
269
+ )
270
+
271
+ # Check for HTTP errors
272
+ if response.status_code >= 400:
273
+ # Don't retry client errors
274
+ if response.status_code in NON_RETRYABLE_STATUS_CODES:
275
+ error = httpx.HTTPStatusError(
276
+ f"HTTP {response.status_code}",
277
+ request=response.request,
278
+ response=response,
279
+ )
280
+ raise translate_http_error(
281
+ error,
282
+ resource_type=resource_type,
283
+ resource_id=resource_id,
284
+ )
285
+
286
+ # Check if retryable
287
+ if response.status_code in RETRYABLE_STATUS_CODES:
288
+ if attempt < self.max_retries:
289
+ delay = calculate_backoff_delay(attempt)
290
+ logger.warning(
291
+ "Retryable error %d on %s %s (attempt %d/%d), "
292
+ "retrying in %.2fs",
293
+ response.status_code,
294
+ method,
295
+ url,
296
+ attempt + 1,
297
+ self.max_retries + 1,
298
+ delay,
299
+ )
300
+ await asyncio.sleep(delay)
301
+ continue
302
+
303
+ # Non-retryable or out of retries
304
+ error = httpx.HTTPStatusError(
305
+ f"HTTP {response.status_code}",
306
+ request=response.request,
307
+ response=response,
308
+ )
309
+ raise translate_http_error(
310
+ error,
311
+ resource_type=resource_type,
312
+ resource_id=resource_id,
313
+ )
314
+
315
+ return response
316
+
317
+ except httpx.ConnectError as e:
318
+ last_error = e
319
+ if attempt < self.max_retries:
320
+ delay = calculate_backoff_delay(attempt)
321
+ logger.warning(
322
+ "Connection error on %s %s (attempt %d/%d), "
323
+ "retrying in %.2fs: %s",
324
+ method,
325
+ url,
326
+ attempt + 1,
327
+ self.max_retries + 1,
328
+ delay,
329
+ e,
330
+ )
331
+ await asyncio.sleep(delay)
332
+ continue
333
+ raise BPAConnectionError(
334
+ f"Failed to connect to BPA API after {self.max_retries + 1} "
335
+ f"attempts: {e}"
336
+ ) from e
337
+
338
+ except httpx.TimeoutException as e:
339
+ last_error = e
340
+ if attempt < self.max_retries:
341
+ delay = calculate_backoff_delay(attempt)
342
+ logger.warning(
343
+ "Timeout on %s %s (attempt %d/%d), retrying in %.2fs",
344
+ method,
345
+ url,
346
+ attempt + 1,
347
+ self.max_retries + 1,
348
+ delay,
349
+ )
350
+ await asyncio.sleep(delay)
351
+ continue
352
+ raise BPATimeoutError(
353
+ f"BPA API request timed out after {self.max_retries + 1} attempts"
354
+ ) from e
355
+
356
+ except BPAClientError:
357
+ # Re-raise BPA errors without wrapping
358
+ raise
359
+
360
+ # Should not reach here, but just in case
361
+ if last_error:
362
+ raise BPAClientError(f"Request failed: {last_error}")
363
+ raise BPAClientError("Request failed for unknown reason")
364
+
365
+ async def get(
366
+ self,
367
+ endpoint: str,
368
+ *,
369
+ path_params: dict[str, Any] | None = None,
370
+ params: dict[str, Any] | None = None,
371
+ resource_type: str | None = None,
372
+ resource_id: str | int | None = None,
373
+ ) -> dict[str, Any]:
374
+ """Execute GET request.
375
+
376
+ Args:
377
+ endpoint: URL endpoint template.
378
+ path_params: Path parameters for URL formatting.
379
+ params: Query parameters.
380
+ resource_type: Resource type for error context.
381
+ resource_id: Resource ID for error context.
382
+
383
+ Returns:
384
+ JSON response as dictionary.
385
+
386
+ Raises:
387
+ BPAClientError: On API errors.
388
+ """
389
+ response = await self._request_with_retry(
390
+ "GET",
391
+ endpoint,
392
+ path_params=path_params,
393
+ params=params,
394
+ resource_type=resource_type,
395
+ resource_id=resource_id,
396
+ )
397
+ # Handle empty response body
398
+ # (API returns 200 with no content for some missing resources)
399
+ if not response.content:
400
+ from mcp_eregistrations_bpa.bpa_client.errors import BPANotFoundError
401
+
402
+ raise BPANotFoundError(
403
+ "Resource not found (empty response)",
404
+ status_code=200,
405
+ )
406
+ return cast(dict[str, Any], response.json())
407
+
408
+ async def get_list(
409
+ self,
410
+ endpoint: str,
411
+ *,
412
+ path_params: dict[str, Any] | None = None,
413
+ params: dict[str, Any] | None = None,
414
+ resource_type: str | None = None,
415
+ ) -> list[dict[str, Any]]:
416
+ """Execute GET request expecting a list response.
417
+
418
+ Args:
419
+ endpoint: URL endpoint template.
420
+ path_params: Path parameters for URL formatting.
421
+ params: Query parameters.
422
+ resource_type: Resource type for error context.
423
+
424
+ Returns:
425
+ JSON response as list of dictionaries.
426
+
427
+ Raises:
428
+ BPAClientError: On API errors.
429
+ """
430
+ response = await self._request_with_retry(
431
+ "GET",
432
+ endpoint,
433
+ path_params=path_params,
434
+ params=params,
435
+ resource_type=resource_type,
436
+ )
437
+ # Handle empty response body - return empty list
438
+ if not response.content:
439
+ return []
440
+ result = response.json()
441
+ if isinstance(result, list):
442
+ return cast(list[dict[str, Any]], result)
443
+ # Some APIs wrap lists in an object
444
+ if isinstance(result, dict) and "items" in result:
445
+ return cast(list[dict[str, Any]], result["items"])
446
+ return [cast(dict[str, Any], result)] if result else []
447
+
448
+ async def post(
449
+ self,
450
+ endpoint: str,
451
+ *,
452
+ path_params: dict[str, Any] | None = None,
453
+ params: dict[str, Any] | None = None,
454
+ json: dict[str, Any] | str | None = None,
455
+ content: str | bytes | None = None,
456
+ resource_type: str | None = None,
457
+ ) -> dict[str, Any]:
458
+ """Execute POST request.
459
+
460
+ Args:
461
+ endpoint: URL endpoint template.
462
+ path_params: Path parameters for URL formatting.
463
+ params: Query parameters.
464
+ json: JSON body (will be serialized).
465
+ content: Raw body content (not serialized).
466
+ resource_type: Resource type for error context.
467
+
468
+ Returns:
469
+ JSON response as dictionary.
470
+
471
+ Raises:
472
+ BPAClientError: On API errors.
473
+ """
474
+ response = await self._request_with_retry(
475
+ "POST",
476
+ endpoint,
477
+ path_params=path_params,
478
+ params=params,
479
+ json=json,
480
+ content=content,
481
+ resource_type=resource_type,
482
+ )
483
+ # Handle empty response body
484
+ if not response.content:
485
+ return {}
486
+ return cast(dict[str, Any], response.json())
487
+
488
+ async def put(
489
+ self,
490
+ endpoint: str,
491
+ *,
492
+ path_params: dict[str, Any] | None = None,
493
+ params: dict[str, Any] | None = None,
494
+ json: dict[str, Any] | None = None,
495
+ resource_type: str | None = None,
496
+ resource_id: str | int | None = None,
497
+ ) -> dict[str, Any]:
498
+ """Execute PUT request.
499
+
500
+ Args:
501
+ endpoint: URL endpoint template.
502
+ path_params: Path parameters for URL formatting.
503
+ params: Query parameters.
504
+ json: JSON body.
505
+ resource_type: Resource type for error context.
506
+ resource_id: Resource ID for error context.
507
+
508
+ Returns:
509
+ JSON response as dictionary.
510
+
511
+ Raises:
512
+ BPAClientError: On API errors.
513
+ """
514
+ response = await self._request_with_retry(
515
+ "PUT",
516
+ endpoint,
517
+ path_params=path_params,
518
+ params=params,
519
+ json=json,
520
+ resource_type=resource_type,
521
+ resource_id=resource_id,
522
+ )
523
+ # Handle empty response body
524
+ if not response.content:
525
+ return {}
526
+ return cast(dict[str, Any], response.json())
527
+
528
+ async def delete(
529
+ self,
530
+ endpoint: str,
531
+ *,
532
+ path_params: dict[str, Any] | None = None,
533
+ params: dict[str, Any] | None = None,
534
+ resource_type: str | None = None,
535
+ resource_id: str | int | None = None,
536
+ ) -> dict[str, Any] | None:
537
+ """Execute DELETE request.
538
+
539
+ Args:
540
+ endpoint: URL endpoint template.
541
+ path_params: Path parameters for URL formatting.
542
+ params: Query parameters.
543
+ resource_type: Resource type for error context.
544
+ resource_id: Resource ID for error context.
545
+
546
+ Returns:
547
+ JSON response as dictionary, or None if no content.
548
+
549
+ Raises:
550
+ BPAClientError: On API errors.
551
+ """
552
+ response = await self._request_with_retry(
553
+ "DELETE",
554
+ endpoint,
555
+ path_params=path_params,
556
+ params=params,
557
+ resource_type=resource_type,
558
+ resource_id=resource_id,
559
+ )
560
+ # Handle empty response (204 No Content or empty body)
561
+ if response.status_code == 204 or not response.content:
562
+ return None
563
+ return cast(dict[str, Any], response.json())
564
+
565
+ async def download_service(
566
+ self,
567
+ service_id: str,
568
+ *,
569
+ options: dict[str, Any] | None = None,
570
+ timeout: float | None = None,
571
+ ) -> tuple[dict[str, Any], int]:
572
+ """Download complete service definition.
573
+
574
+ This endpoint can return large payloads (5-15MB) and may take
575
+ longer than the default timeout. Uses retry logic for transient errors.
576
+
577
+ Args:
578
+ service_id: The BPA service UUID.
579
+ options: Export selection options. Defaults to all-inclusive.
580
+ timeout: Custom timeout in seconds (default: 120s for exports).
581
+
582
+ Returns:
583
+ Tuple of (export_data, size_bytes) where:
584
+ - export_data: The complete service definition JSON
585
+ - size_bytes: Size of the response in bytes
586
+
587
+ Raises:
588
+ BPAClientError: On API errors.
589
+ """
590
+ # Default options: include all components
591
+ default_options = {
592
+ "serviceSelected": True,
593
+ "costsSelected": True,
594
+ "requirementsSelected": True,
595
+ "resultsSelected": True,
596
+ "activityConditionsSelected": True,
597
+ "registrationLawsSelected": True,
598
+ "serviceLocationsSelected": True,
599
+ "serviceTutorialsSelected": True,
600
+ "serviceTranslationsSelected": True,
601
+ "guideFormSelected": True,
602
+ "applicantFormSelected": True,
603
+ "sendFileFormSelected": True,
604
+ "paymentFormSelected": True,
605
+ "catalogsSelected": True,
606
+ "rolesSelected": True,
607
+ "registrationsSelected": True,
608
+ "determinantsSelected": True,
609
+ "printDocumentsSelected": True,
610
+ "botsSelected": True,
611
+ "copyService": False,
612
+ }
613
+
614
+ # Merge with provided options
615
+ export_options = {**default_options, **(options or {})}
616
+
617
+ # Use longer timeout for exports (default 120s)
618
+ export_timeout = timeout or 120.0
619
+
620
+ client = self._get_client()
621
+ url = f"/download_service/{service_id}"
622
+ headers = await self._get_auth_header()
623
+
624
+ logger.info("BPA Export Request: POST %s%s", self.base_url, url)
625
+
626
+ last_error: Exception | None = None
627
+
628
+ for attempt in range(self.max_retries + 1):
629
+ try:
630
+ response = await client.post(
631
+ url,
632
+ headers=headers,
633
+ json=export_options,
634
+ timeout=httpx.Timeout(export_timeout),
635
+ )
636
+
637
+ if response.status_code >= 400:
638
+ # Don't retry client errors
639
+ if response.status_code in NON_RETRYABLE_STATUS_CODES:
640
+ error = httpx.HTTPStatusError(
641
+ f"HTTP {response.status_code}",
642
+ request=response.request,
643
+ response=response,
644
+ )
645
+ raise translate_http_error(
646
+ error,
647
+ resource_type="service",
648
+ resource_id=service_id,
649
+ )
650
+
651
+ # Check if retryable
652
+ if response.status_code in RETRYABLE_STATUS_CODES:
653
+ if attempt < self.max_retries:
654
+ delay = calculate_backoff_delay(attempt)
655
+ logger.warning(
656
+ "Retryable error %d on export (attempt %d/%d), "
657
+ "retrying in %.2fs",
658
+ response.status_code,
659
+ attempt + 1,
660
+ self.max_retries + 1,
661
+ delay,
662
+ )
663
+ await asyncio.sleep(delay)
664
+ continue
665
+
666
+ # Non-retryable or out of retries
667
+ error = httpx.HTTPStatusError(
668
+ f"HTTP {response.status_code}",
669
+ request=response.request,
670
+ response=response,
671
+ )
672
+ raise translate_http_error(
673
+ error,
674
+ resource_type="service",
675
+ resource_id=service_id,
676
+ )
677
+
678
+ # Get response size
679
+ size_bytes = len(response.content)
680
+ logger.info(
681
+ "BPA Export Response: POST %s → %d %s (%.2f MB)",
682
+ url,
683
+ response.status_code,
684
+ response.reason_phrase,
685
+ size_bytes / (1024 * 1024),
686
+ )
687
+
688
+ # Parse JSON with error handling
689
+ try:
690
+ export_data = response.json()
691
+ except ValueError as e:
692
+ raise BPAClientError(
693
+ f"Failed to parse export response as JSON: {e}"
694
+ ) from e
695
+
696
+ return cast(dict[str, Any], export_data), size_bytes
697
+
698
+ except httpx.ConnectError as e:
699
+ last_error = e
700
+ if attempt < self.max_retries:
701
+ delay = calculate_backoff_delay(attempt)
702
+ logger.warning(
703
+ "Connection error on export (attempt %d/%d), "
704
+ "retrying in %.2fs: %s",
705
+ attempt + 1,
706
+ self.max_retries + 1,
707
+ delay,
708
+ e,
709
+ )
710
+ await asyncio.sleep(delay)
711
+ continue
712
+ raise BPAConnectionError(
713
+ f"Failed to connect to BPA API after {self.max_retries + 1} "
714
+ f"attempts: {e}"
715
+ ) from e
716
+
717
+ except httpx.TimeoutException as e:
718
+ last_error = e
719
+ if attempt < self.max_retries:
720
+ delay = calculate_backoff_delay(attempt)
721
+ logger.warning(
722
+ "Timeout on export (attempt %d/%d), retrying in %.2fs",
723
+ attempt + 1,
724
+ self.max_retries + 1,
725
+ delay,
726
+ )
727
+ await asyncio.sleep(delay)
728
+ continue
729
+ raise BPATimeoutError(
730
+ f"Export request timed out after {self.max_retries + 1} attempts"
731
+ ) from e
732
+
733
+ except BPAClientError:
734
+ # Re-raise BPA errors without wrapping
735
+ raise
736
+
737
+ # Should not reach here, but just in case
738
+ if last_error:
739
+ raise BPAClientError(f"Export failed: {last_error}")
740
+ raise BPAClientError("Export failed for unknown reason")