fluvius-energy-api 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.
@@ -0,0 +1,334 @@
1
+ """Main API client for the Fluvius Energy API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from enum import Enum
7
+ from types import TracebackType
8
+ from typing import Self
9
+
10
+ from ._http import HTTPClient
11
+ from .auth import TokenProvider
12
+ from .credentials import FluviusCredentials
13
+ from .models.energy import GetEnergyResponseApiDataResponse
14
+ from .models.enums import (
15
+ DataServiceType,
16
+ EnergyType,
17
+ MandateRenewalStatus,
18
+ MandateStatus,
19
+ PeriodType,
20
+ )
21
+ from .models.mandate import (
22
+ CreateMandateRequest,
23
+ CreateMandateResponseApiDataResponse,
24
+ GetMandatesResponseApiDataResponse,
25
+ )
26
+ from .models.session import (
27
+ ClientSessionDataService,
28
+ CreateClientSessionRequest,
29
+ CreateClientSessionResponseApiDataResponse,
30
+ )
31
+
32
+
33
+ class Environment(str, Enum):
34
+ """Environment configuration for the Fluvius API."""
35
+
36
+ PRODUCTION = "https://apihub.fluvius.be/esco-live/v3"
37
+ SANDBOX = "https://apihub.fluvius.be/esco-sbx/v3"
38
+
39
+
40
+ class FluviusEnergyClient:
41
+ """Client for interacting with the Fluvius Energy API.
42
+
43
+ Authentication is handled automatically using OAuth2 with Azure AD.
44
+ Credentials can be loaded from environment variables or passed explicitly.
45
+
46
+ Args:
47
+ credentials: OAuth2 credentials for authentication. If not provided,
48
+ credentials are loaded from environment variables.
49
+ environment: The API environment to use. Defaults to SANDBOX.
50
+ timeout: Request timeout in seconds. Defaults to 30.0.
51
+
52
+ Environment Variables:
53
+ FLUVIUS_SUBSCRIPTION_KEY: Azure API Management subscription key.
54
+ FLUVIUS_CLIENT_ID: Azure AD application (client) ID.
55
+ FLUVIUS_TENANT_ID: Azure AD tenant ID.
56
+ FLUVIUS_SCOPE: OAuth2 scope for the Fluvius API.
57
+ FLUVIUS_CERTIFICATE_THUMBPRINT: Hexadecimal certificate thumbprint.
58
+ FLUVIUS_PRIVATE_KEY: RSA private key (PEM format or base64-encoded).
59
+ FLUVIUS_PRIVATE_KEY_PATH: Path to private key file (alternative).
60
+
61
+ Example:
62
+ ```python
63
+ from fluvius_energy_api import FluviusEnergyClient
64
+
65
+ # Using environment variables (recommended)
66
+ with FluviusEnergyClient() as client:
67
+ mandates = client.get_mandates()
68
+
69
+ # Explicit credentials
70
+ from fluvius_energy_api import FluviusCredentials, Environment
71
+
72
+ credentials = FluviusCredentials(
73
+ subscription_key="...",
74
+ client_id="...",
75
+ tenant_id="...",
76
+ scope="...",
77
+ certificate_thumbprint="...",
78
+ private_key="-----BEGIN RSA PRIVATE KEY-----...",
79
+ )
80
+ with FluviusEnergyClient(credentials=credentials, environment=Environment.PRODUCTION) as client:
81
+ mandates = client.get_mandates()
82
+ ```
83
+ """
84
+
85
+ def __init__(
86
+ self,
87
+ credentials: FluviusCredentials | None = None,
88
+ environment: Environment = Environment.SANDBOX,
89
+ timeout: float = 30.0,
90
+ ) -> None:
91
+ if credentials is None:
92
+ credentials = FluviusCredentials.from_env()
93
+
94
+ self._token_provider = TokenProvider(credentials)
95
+ self._http = HTTPClient(
96
+ base_url=environment.value,
97
+ token_provider=self._token_provider,
98
+ timeout=timeout,
99
+ )
100
+
101
+ def close(self) -> None:
102
+ """Close the client and release resources."""
103
+ self._http.close()
104
+
105
+ def __enter__(self) -> Self:
106
+ """Enter the context manager."""
107
+ return self
108
+
109
+ def __exit__(
110
+ self,
111
+ exc_type: type[BaseException] | None,
112
+ exc_val: BaseException | None,
113
+ exc_tb: TracebackType | None,
114
+ ) -> None:
115
+ """Exit the context manager."""
116
+ self.close()
117
+
118
+ def create_client_session(
119
+ self,
120
+ data_access_contract_number: str,
121
+ reference_number: str,
122
+ flow: str,
123
+ data_services: list[ClientSessionDataService] | None = None,
124
+ number_of_eans: int | None = None,
125
+ return_url_success: str | None = None,
126
+ return_url_failed: str | None = None,
127
+ sso: bool | None = None,
128
+ enterprise_number: str | None = None,
129
+ ) -> CreateClientSessionResponseApiDataResponse:
130
+ """Create a new client session.
131
+
132
+ Args:
133
+ data_access_contract_number: The data access contract number.
134
+ reference_number: The reference number.
135
+ flow: The flow type (B2C or B2B).
136
+ data_services: Optional list of data services.
137
+ number_of_eans: Optional number of EANs.
138
+ return_url_success: URL to redirect to on success.
139
+ return_url_failed: URL to redirect to on failure.
140
+ sso: Whether to use Single Sign On.
141
+ enterprise_number: The enterprise number.
142
+
143
+ Returns:
144
+ CreateClientSessionResponseApiDataResponse: The API response.
145
+ """
146
+ request = CreateClientSessionRequest(
147
+ data_access_contract_number=data_access_contract_number,
148
+ reference_number=reference_number,
149
+ flow=flow,
150
+ data_services=data_services,
151
+ number_of_eans=number_of_eans,
152
+ return_url_success=return_url_success,
153
+ return_url_failed=return_url_failed,
154
+ sso=sso,
155
+ enterprise_number=enterprise_number,
156
+ )
157
+
158
+ response_data = self._http.post(
159
+ "/api/shortUrlIdentifier",
160
+ json_data=request.model_dump(by_alias=True, exclude_none=True),
161
+ )
162
+
163
+ return CreateClientSessionResponseApiDataResponse.model_validate(response_data)
164
+
165
+ def get_mandates(
166
+ self,
167
+ reference_number: str | None = None,
168
+ ean: str | None = None,
169
+ data_service_types: list[DataServiceType] | None = None,
170
+ energy_type: EnergyType | None = None,
171
+ status: MandateStatus | None = None,
172
+ mandate_expiration_date: datetime | None = None,
173
+ renewal_status: MandateRenewalStatus | None = None,
174
+ last_updated_from: datetime | None = None,
175
+ last_updated_to: datetime | None = None,
176
+ ) -> GetMandatesResponseApiDataResponse:
177
+ """Get mandates that match the specified filters.
178
+
179
+ Args:
180
+ reference_number: Custom reference number.
181
+ ean: GSRN EAN-code that identifies the installation.
182
+ data_service_types: Types of the data service.
183
+ energy_type: The discipline (E for electricity, G for gas).
184
+ status: Status of the mandate.
185
+ mandate_expiration_date: Date and time of the mandate expiration.
186
+ renewal_status: Renewal status of the mandate.
187
+ last_updated_from: Start date and time of the last updated filter.
188
+ last_updated_to: End date and time of the last updated filter.
189
+
190
+ Returns:
191
+ GetMandatesResponseApiDataResponse: The API response containing mandates.
192
+ """
193
+ params: dict[str, str] = {}
194
+
195
+ if reference_number is not None:
196
+ params["referenceNumber"] = reference_number
197
+ if ean is not None:
198
+ params["ean"] = ean
199
+ if data_service_types is not None:
200
+ params["dataServiceTypes"] = ",".join(
201
+ dst.value if isinstance(dst, DataServiceType) else dst
202
+ for dst in data_service_types
203
+ )
204
+ if energy_type is not None:
205
+ params["energyType"] = (
206
+ energy_type.value
207
+ if isinstance(energy_type, EnergyType)
208
+ else energy_type
209
+ )
210
+ if status is not None:
211
+ params["status"] = (
212
+ status.value if isinstance(status, MandateStatus) else status
213
+ )
214
+ if mandate_expiration_date is not None:
215
+ params["mandateExpirationDate"] = mandate_expiration_date.strftime("%Y-%m-%dT%H:%M:%SZ")
216
+ if renewal_status is not None:
217
+ params["renewalStatus"] = (
218
+ renewal_status.value
219
+ if isinstance(renewal_status, MandateRenewalStatus)
220
+ else renewal_status
221
+ )
222
+ if last_updated_from is not None:
223
+ params["lastUpdatedFrom"] = last_updated_from.strftime("%Y-%m-%dT%H:%M:%SZ")
224
+ if last_updated_to is not None:
225
+ params["lastUpdatedTo"] = last_updated_to.strftime("%Y-%m-%dT%H:%M:%SZ")
226
+
227
+ response_data = self._http.get("/api/mandate", params=params or None)
228
+
229
+ return GetMandatesResponseApiDataResponse.model_validate(response_data)
230
+
231
+ def get_energy(
232
+ self,
233
+ ean: str,
234
+ period_type: PeriodType,
235
+ reference_number: str | None = None,
236
+ granularity: str | None = None,
237
+ complex_energy_types: str | None = None,
238
+ from_date: datetime | None = None,
239
+ to_date: datetime | None = None,
240
+ ) -> GetEnergyResponseApiDataResponse:
241
+ """Get energy measurements that match the specified filters.
242
+
243
+ Args:
244
+ ean: GSRN EAN-code that identifies the installation (required).
245
+ period_type: Type of period for the query (required).
246
+ reference_number: Custom reference number.
247
+ granularity: Granularity for energy measurements (e.g., "hourly_quarterhourly,daily").
248
+ complex_energy_types: Types of complex energy (e.g., "active,reactive").
249
+ from_date: Start date and time of the query.
250
+ to_date: End date and time of the query.
251
+
252
+ Returns:
253
+ GetEnergyResponseApiDataResponse: The API response containing energy data.
254
+ """
255
+ params: dict[str, str] = {
256
+ "ean": ean,
257
+ "periodType": (
258
+ period_type.value
259
+ if isinstance(period_type, PeriodType)
260
+ else period_type
261
+ ),
262
+ }
263
+
264
+ if reference_number is not None:
265
+ params["referenceNumber"] = reference_number
266
+ if granularity is not None:
267
+ params["granularity"] = granularity
268
+ if complex_energy_types is not None:
269
+ params["complexEnergyTypes"] = complex_energy_types
270
+ if from_date is not None:
271
+ params["from"] = from_date.strftime("%Y-%m-%dT%H:%M:%SZ")
272
+ if to_date is not None:
273
+ params["to"] = to_date.strftime("%Y-%m-%dT%H:%M:%SZ")
274
+
275
+ response_data = self._http.get("/api/mandate/energy", params=params)
276
+
277
+ return GetEnergyResponseApiDataResponse.model_validate(response_data)
278
+
279
+ def create_mock_mandate(
280
+ self,
281
+ reference_number: str,
282
+ ean: str,
283
+ data_service_type: DataServiceType,
284
+ data_period_from: str | None = None,
285
+ data_period_to: str | None = None,
286
+ status: MandateStatus | None = None,
287
+ mandate_expiration_date: str | None = None,
288
+ renewal_status: MandateRenewalStatus | None = None,
289
+ ) -> CreateMandateResponseApiDataResponse:
290
+ """Create a mock mandate in the sandbox environment.
291
+
292
+ This endpoint is only available in the sandbox environment.
293
+
294
+ Args:
295
+ reference_number: Custom reference number for the mandate.
296
+ ean: GSRN EAN-code that identifies the headpoint.
297
+ data_service_type: The data service type for the mandate.
298
+ data_period_from: Start date/time of the data period (ISO format).
299
+ data_period_to: End date/time of the data period (ISO format).
300
+ status: Status of the mandate to create.
301
+ mandate_expiration_date: Expiration date for the mandate (ISO format).
302
+ renewal_status: Renewal status of the mandate.
303
+
304
+ Returns:
305
+ CreateMandateResponseApiDataResponse: The API response.
306
+
307
+ Example:
308
+ ```python
309
+ response = client.create_mock_mandate(
310
+ reference_number="test-mandate-001",
311
+ ean="541448860000000016",
312
+ data_service_type=DataServiceType.VH_DAG,
313
+ data_period_from="2024-01-01T00:00:00Z",
314
+ status=MandateStatus.APPROVED,
315
+ )
316
+ ```
317
+ """
318
+ request = CreateMandateRequest(
319
+ reference_number=reference_number,
320
+ ean=ean,
321
+ data_service_type=data_service_type,
322
+ data_period_from=data_period_from,
323
+ data_period_to=data_period_to,
324
+ status=status,
325
+ mandate_expiration_date=mandate_expiration_date,
326
+ renewal_status=renewal_status,
327
+ )
328
+
329
+ response_data = self._http.post(
330
+ "/api/mandate/mock",
331
+ json_data=request.model_dump(by_alias=True, exclude_none=True),
332
+ )
333
+
334
+ return CreateMandateResponseApiDataResponse.model_validate(response_data)
@@ -0,0 +1,215 @@
1
+ """Credentials configuration for Fluvius API authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import os
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from .exceptions import ConfigurationError
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class FluviusCredentials:
15
+ """OAuth2 credentials for Fluvius API authentication.
16
+
17
+ Supports two authentication methods:
18
+ 1. Certificate-based (production): Uses certificate_thumbprint + private_key
19
+ 2. Client secret-based (sandbox): Uses client_secret
20
+
21
+ Attributes:
22
+ subscription_key: Azure API Management subscription key.
23
+ client_id: Azure AD application (client) ID.
24
+ tenant_id: Azure AD tenant ID.
25
+ scope: OAuth2 scope for the Fluvius API.
26
+ certificate_thumbprint: Hexadecimal thumbprint of the certificate (for certificate auth).
27
+ private_key: RSA private key in PEM format (for certificate auth).
28
+ client_secret: Client secret (for secret-based auth, typically sandbox).
29
+
30
+ Example:
31
+ ```python
32
+ # Production with certificate
33
+ prod_creds = FluviusCredentials(
34
+ subscription_key="...",
35
+ client_id="...",
36
+ tenant_id="...",
37
+ scope="...",
38
+ certificate_thumbprint="...",
39
+ private_key="-----BEGIN RSA PRIVATE KEY-----...",
40
+ )
41
+
42
+ # Sandbox with client secret
43
+ sandbox_creds = FluviusCredentials(
44
+ subscription_key="...",
45
+ client_id="...",
46
+ tenant_id="...",
47
+ scope="...",
48
+ client_secret="...",
49
+ )
50
+ ```
51
+ """
52
+
53
+ subscription_key: str
54
+ client_id: str
55
+ tenant_id: str
56
+ scope: str
57
+ data_access_contract_number: str
58
+ # Certificate-based auth (production)
59
+ certificate_thumbprint: str | None = None
60
+ private_key: str | None = None
61
+ # Client secret auth (sandbox)
62
+ client_secret: str | None = None
63
+
64
+ def __post_init__(self) -> None:
65
+ """Validate that all required fields are non-empty."""
66
+ # Always required
67
+ for field_name in ["subscription_key", "client_id", "tenant_id", "scope", "data_access_contract_number"]:
68
+ value = getattr(self, field_name)
69
+ if not value or not value.strip():
70
+ raise ConfigurationError(
71
+ f"Missing required credential: {field_name}"
72
+ )
73
+
74
+ # Must have either certificate-based or secret-based auth
75
+ has_certificate = (
76
+ self.certificate_thumbprint
77
+ and self.certificate_thumbprint.strip()
78
+ and self.private_key
79
+ and self.private_key.strip()
80
+ )
81
+ has_secret = self.client_secret and self.client_secret.strip()
82
+
83
+ if not has_certificate and not has_secret:
84
+ raise ConfigurationError(
85
+ "Missing authentication credentials: provide either "
86
+ "(certificate_thumbprint + private_key) or client_secret"
87
+ )
88
+
89
+ @property
90
+ def uses_certificate(self) -> bool:
91
+ """Return True if using certificate-based authentication."""
92
+ return bool(
93
+ self.certificate_thumbprint
94
+ and self.certificate_thumbprint.strip()
95
+ and self.private_key
96
+ and self.private_key.strip()
97
+ )
98
+
99
+ @classmethod
100
+ def from_env(cls, prefix: str = "FLUVIUS") -> FluviusCredentials:
101
+ """Load credentials from environment variables.
102
+
103
+ Args:
104
+ prefix: Environment variable prefix. Defaults to "FLUVIUS".
105
+ Use "FLUVIUS_SANDBOX" for sandbox-specific credentials.
106
+ Common values (subscription_key, client_id, tenant_id, scope)
107
+ will fall back to FLUVIUS_* if not found with the prefix.
108
+
109
+ Environment Variables:
110
+ {PREFIX}_SUBSCRIPTION_KEY: Azure API Management subscription key.
111
+ {PREFIX}_CLIENT_ID: Azure AD application (client) ID.
112
+ {PREFIX}_TENANT_ID: Azure AD tenant ID.
113
+ {PREFIX}_SCOPE: OAuth2 scope for the Fluvius API.
114
+
115
+ For certificate auth (production):
116
+ {PREFIX}_CERTIFICATE_THUMBPRINT: Hexadecimal certificate thumbprint.
117
+ {PREFIX}_PRIVATE_KEY: RSA private key (PEM format or base64-encoded).
118
+ {PREFIX}_PRIVATE_KEY_PATH: Path to private key file (alternative).
119
+
120
+ For client secret auth (sandbox):
121
+ {PREFIX}_CLIENT_SECRET: Client secret.
122
+
123
+ Always required:
124
+ {PREFIX}_DATA_ACCESS_CONTRACT_NUMBER: Data access contract number.
125
+
126
+ Example:
127
+ ```python
128
+ # Production (certificate auth)
129
+ prod_creds = FluviusCredentials.from_env()
130
+
131
+ # Sandbox (client secret auth, falls back to FLUVIUS_* for common values)
132
+ sandbox_creds = FluviusCredentials.from_env(prefix="FLUVIUS_SANDBOX")
133
+ ```
134
+
135
+ Returns:
136
+ FluviusCredentials: The loaded credentials.
137
+
138
+ Raises:
139
+ ConfigurationError: If required environment variables are missing.
140
+ """
141
+ private_key = cls._load_private_key(prefix)
142
+
143
+ # Helper to get env var with fallback to default prefix
144
+ def get_env(name: str, fallback_prefix: str = "FLUVIUS") -> str:
145
+ value = os.environ.get(f"{prefix}_{name}", "")
146
+ if not value and prefix != fallback_prefix:
147
+ value = os.environ.get(f"{fallback_prefix}_{name}", "")
148
+ return value
149
+
150
+ return cls(
151
+ subscription_key=get_env("SUBSCRIPTION_KEY"),
152
+ client_id=get_env("CLIENT_ID"),
153
+ tenant_id=get_env("TENANT_ID"),
154
+ scope=get_env("SCOPE"),
155
+ certificate_thumbprint=get_env("CERTIFICATE_THUMBPRINT") or None,
156
+ private_key=private_key or None,
157
+ client_secret=get_env("CLIENT_SECRET") or None,
158
+ data_access_contract_number=get_env("DATA_ACCESS_CONTRACT_NUMBER"),
159
+ )
160
+
161
+ @staticmethod
162
+ def _load_private_key(prefix: str = "FLUVIUS") -> str:
163
+ """Load the private key from environment variable or file.
164
+
165
+ Args:
166
+ prefix: Environment variable prefix.
167
+
168
+ Returns:
169
+ The private key in PEM format, or empty string if not found.
170
+ """
171
+ private_key = os.environ.get(f"{prefix}_PRIVATE_KEY")
172
+ private_key_path = os.environ.get(f"{prefix}_PRIVATE_KEY_PATH")
173
+
174
+ # Fall back to default prefix if using custom prefix
175
+ if not private_key and not private_key_path and prefix != "FLUVIUS":
176
+ private_key = os.environ.get("FLUVIUS_PRIVATE_KEY")
177
+ private_key_path = os.environ.get("FLUVIUS_PRIVATE_KEY_PATH")
178
+
179
+ if private_key:
180
+ return _decode_private_key(private_key)
181
+
182
+ if private_key_path:
183
+ path = Path(private_key_path)
184
+ if not path.exists():
185
+ raise ConfigurationError(
186
+ f"Private key file not found: {private_key_path}"
187
+ )
188
+ try:
189
+ return path.read_text()
190
+ except OSError as e:
191
+ raise ConfigurationError(
192
+ f"Failed to read private key file: {e}"
193
+ ) from e
194
+
195
+ return ""
196
+
197
+
198
+ def _decode_private_key(key: str) -> str:
199
+ """Decode a private key that may be base64-encoded.
200
+
201
+ Args:
202
+ key: The private key, either in PEM format or base64-encoded.
203
+
204
+ Returns:
205
+ The private key in PEM format.
206
+ """
207
+ if key.startswith("-----BEGIN"):
208
+ return key
209
+
210
+ try:
211
+ cleaned = key.replace(" ", "").replace("\n", "").replace("\r", "").replace("\t", "")
212
+ decoded = base64.b64decode(cleaned, validate=True).decode("utf-8")
213
+ return decoded
214
+ except (ValueError, UnicodeDecodeError):
215
+ return key
@@ -0,0 +1,104 @@
1
+ """Custom exceptions for the Fluvius Energy API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from .models.base import ErrorResponse, ErrorValidationResponse
9
+
10
+
11
+ class FluviusAPIError(Exception):
12
+ """Base exception for Fluvius API errors."""
13
+
14
+ def __init__(
15
+ self,
16
+ message: str,
17
+ status_code: int | None = None,
18
+ error_response: ErrorResponse | ErrorValidationResponse | None = None,
19
+ ) -> None:
20
+ super().__init__(message)
21
+ self.message = message
22
+ self.status_code = status_code
23
+ self.error_response = error_response
24
+
25
+ def __str__(self) -> str:
26
+ if self.status_code:
27
+ return f"[{self.status_code}] {self.message}"
28
+ return self.message
29
+
30
+
31
+ class ConfigurationError(FluviusAPIError):
32
+ """Raised when credentials or configuration is missing or invalid."""
33
+
34
+ def __init__(self, message: str) -> None:
35
+ super().__init__(message)
36
+
37
+
38
+ class AuthenticationError(FluviusAPIError):
39
+ """Raised when authentication fails (401 Unauthorized)."""
40
+
41
+ def __init__(
42
+ self,
43
+ message: str = "Authentication failed. Check your bearer token.",
44
+ error_response: ErrorResponse | ErrorValidationResponse | None = None,
45
+ ) -> None:
46
+ super().__init__(message, status_code=401, error_response=error_response)
47
+
48
+
49
+ class ForbiddenError(FluviusAPIError):
50
+ """Raised when access is forbidden (403 Forbidden)."""
51
+
52
+ def __init__(
53
+ self,
54
+ message: str = "Access forbidden. You don't have permission to access this resource.",
55
+ error_response: ErrorResponse | ErrorValidationResponse | None = None,
56
+ ) -> None:
57
+ super().__init__(message, status_code=403, error_response=error_response)
58
+
59
+
60
+ class NotFoundError(FluviusAPIError):
61
+ """Raised when a resource is not found (404 Not Found)."""
62
+
63
+ def __init__(
64
+ self,
65
+ message: str = "Resource not found.",
66
+ error_response: ErrorResponse | ErrorValidationResponse | None = None,
67
+ ) -> None:
68
+ super().__init__(message, status_code=404, error_response=error_response)
69
+
70
+
71
+ class ValidationError(FluviusAPIError):
72
+ """Raised when request validation fails (400 Bad Request)."""
73
+
74
+ def __init__(
75
+ self,
76
+ message: str = "Request validation failed.",
77
+ error_response: ErrorValidationResponse | None = None,
78
+ ) -> None:
79
+ super().__init__(message, status_code=400, error_response=error_response)
80
+ self.validation_errors = (
81
+ error_response.validation_error_messages if error_response else None
82
+ )
83
+
84
+
85
+ class ServerError(FluviusAPIError):
86
+ """Raised when a server error occurs (500 Internal Server Error)."""
87
+
88
+ def __init__(
89
+ self,
90
+ message: str = "Internal server error.",
91
+ error_response: ErrorResponse | ErrorValidationResponse | None = None,
92
+ ) -> None:
93
+ super().__init__(message, status_code=500, error_response=error_response)
94
+
95
+
96
+ class ServiceUnavailableError(FluviusAPIError):
97
+ """Raised when the service is unavailable (503 Service Unavailable)."""
98
+
99
+ def __init__(
100
+ self,
101
+ message: str = "Service unavailable. Please try again later.",
102
+ error_response: ErrorResponse | ErrorValidationResponse | None = None,
103
+ ) -> None:
104
+ super().__init__(message, status_code=503, error_response=error_response)