FIAS-RU 0.1.2__tar.gz

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,23 @@
1
+ """
2
+ FIAS_RU/FIAS_RU/SPAS/__init__.py
3
+ """
4
+
5
+ from .client import SPAS
6
+ from .models import (
7
+ AddressType,
8
+ AddressItem,
9
+ SearchHint,
10
+ AddressDetails,
11
+ AddressObject,
12
+ StructuredAddress,
13
+ )
14
+
15
+ __all__ = [
16
+ "SPAS",
17
+ "AddressType",
18
+ "AddressItem",
19
+ "SearchHint",
20
+ "AddressDetails",
21
+ "AddressObject",
22
+ "StructuredAddress",
23
+ ]
@@ -0,0 +1,158 @@
1
+ """
2
+ FIAS_RU/FIAS_RU/SPAS/base.py
3
+
4
+ """
5
+
6
+ from typing import Optional
7
+ import httpx
8
+ import time
9
+ from threading import Lock
10
+ from collections import deque
11
+
12
+
13
+ class RateLimiter:
14
+ """Простой rate limiter для предотвращения перегрузки API"""
15
+
16
+ def __init__(self, max_requests: int = 100, time_window: float = 60.0):
17
+ """
18
+ Args:
19
+ max_requests: Максимум запросов
20
+ time_window: Временное окно в секундах
21
+ """
22
+ self.max_requests = max_requests
23
+ self.time_window = time_window
24
+ self.requests = deque()
25
+ self.lock = Lock()
26
+
27
+ def acquire(self):
28
+ """Ждём, пока не сможем сделать запрос"""
29
+ with self.lock:
30
+ now = time.time()
31
+
32
+ # Удаляем старые записи
33
+ while self.requests and self.requests[0] < now - self.time_window:
34
+ self.requests.popleft()
35
+
36
+ # Если превышен лимит, ждём
37
+ if len(self.requests) >= self.max_requests:
38
+ sleep_time = self.requests[0] + self.time_window - now
39
+ if sleep_time > 0:
40
+ time.sleep(sleep_time)
41
+ # Рекурсивно пробуем снова
42
+ return self.acquire()
43
+
44
+ # Добавляем текущий запрос
45
+ self.requests.append(now)
46
+
47
+
48
+ class FIASClient:
49
+ """
50
+ Улучшенный базовый клиент для HTTP запросов к ФИАС API
51
+
52
+ Новые возможности:
53
+ - Connection pooling для повторного использования соединений
54
+ - Rate limiting для предотвращения блокировки
55
+ - Настраиваемые retry и таймауты
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ base_url: str,
61
+ timeout: float = 30.0,
62
+ max_connections: int = 100,
63
+ max_keepalive_connections: int = 20,
64
+ rate_limit_requests: int = 100,
65
+ rate_limit_window: float = 60.0
66
+ ):
67
+ """
68
+ Args:
69
+ base_url: Базовый URL API
70
+ timeout: Таймаут запросов в секундах
71
+ max_connections: Максимум одновременных соединений
72
+ max_keepalive_connections: Максимум keep-alive соединений
73
+ rate_limit_requests: Максимум запросов в окне
74
+ rate_limit_window: Размер окна rate limit (секунды)
75
+ """
76
+ self.base_url = base_url.rstrip('/')
77
+ self.timeout = timeout
78
+ self._sync_client: Optional[httpx.Client] = None
79
+ self._async_client: Optional[httpx.AsyncClient] = None
80
+
81
+ # Rate limiter
82
+ self.rate_limiter = RateLimiter(rate_limit_requests, rate_limit_window)
83
+
84
+ # Настройки connection pooling
85
+ self.limits = httpx.Limits(
86
+ max_connections=max_connections,
87
+ max_keepalive_connections=max_keepalive_connections
88
+ )
89
+
90
+ @property
91
+ def sync_client(self) -> httpx.Client:
92
+ """Ленивая инициализация синхронного клиента с connection pooling"""
93
+ if self._sync_client is None:
94
+ self._sync_client = httpx.Client(
95
+ base_url=self.base_url,
96
+ timeout=self.timeout,
97
+ limits=self.limits,
98
+ follow_redirects=True
99
+ )
100
+ return self._sync_client
101
+
102
+ @property
103
+ def async_client(self) -> httpx.AsyncClient:
104
+ """Ленивая инициализация асинхронного клиента с connection pooling"""
105
+ if self._async_client is None:
106
+ self._async_client = httpx.AsyncClient(
107
+ base_url=self.base_url,
108
+ timeout=self.timeout,
109
+ limits=self.limits,
110
+ follow_redirects=True
111
+ )
112
+ return self._async_client
113
+
114
+ def close(self):
115
+ """Закрыть соединения корректно"""
116
+ if self._sync_client:
117
+ self._sync_client.close()
118
+ self._sync_client = None
119
+ if self._async_client:
120
+ import asyncio
121
+ try:
122
+ loop = asyncio.get_event_loop()
123
+ if loop.is_running():
124
+ loop.create_task(self._async_client.aclose())
125
+ else:
126
+ loop.run_until_complete(self._async_client.aclose())
127
+ except RuntimeError:
128
+ asyncio.run(self._async_client.aclose())
129
+ finally:
130
+ self._async_client = None
131
+
132
+ async def aclose(self):
133
+ """Асинхронно закрыть соединения"""
134
+ if self._async_client:
135
+ await self._async_client.aclose()
136
+ self._async_client = None
137
+ if self._sync_client:
138
+ self._sync_client.close()
139
+ self._sync_client = None
140
+
141
+ def __enter__(self):
142
+ return self
143
+
144
+ def __exit__(self, *args):
145
+ self.close()
146
+
147
+ async def __aenter__(self):
148
+ return self
149
+
150
+ async def __aexit__(self, *args):
151
+ await self.aclose()
152
+
153
+ def __del__(self):
154
+ """Убедимся, что соединения закрыты при удалении объекта"""
155
+ try:
156
+ self.close()
157
+ except:
158
+ pass
@@ -0,0 +1,460 @@
1
+ """
2
+ FIAS_RU/FIAS_RU/SPAS/client.py
3
+ """
4
+
5
+ from typing import List, Optional, Union
6
+ import os
7
+ import re
8
+ import logging
9
+ from functools import wraps
10
+ from .base import FIASClient
11
+ from .models import AddressType, AddressItem, SearchHint, AddressDetails
12
+ from .exceptions import (
13
+ FIASValidationError,
14
+ FIASAPIError,
15
+ FIASTimeoutError,
16
+ FIASNetworkError
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Константы
22
+ DEFAULT_BASE_URL = "https://fias-public-service.nalog.ru"
23
+ GUID_PATTERN = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE)
24
+ CADASTRAL_PATTERN = re.compile(r'^\d{2}:\d{2}:\d{6,7}:\d+$')
25
+
26
+
27
+ def auto_retry(func):
28
+ """Декоратор для автоматических повторов с логированием"""
29
+ @wraps(func)
30
+ def wrapper(self, *args, **kwargs):
31
+ max_attempts = self.max_retries
32
+ for attempt in range(max_attempts):
33
+ try:
34
+ return func(self, *args, **kwargs)
35
+ except (FIASTimeoutError, FIASNetworkError) as e:
36
+ if attempt < max_attempts - 1:
37
+ logger.warning(f"Попытка {attempt + 1}/{max_attempts} провалилась, повтор...")
38
+ else:
39
+ raise
40
+ except FIASAPIError:
41
+ raise # Не повторяем при API ошибках
42
+ return wrapper
43
+
44
+
45
+ class SPAS(FIASClient):
46
+ """
47
+ 🚀 Максимально простой клиент для работы с ФИАС
48
+
49
+ Быстрый старт:
50
+ >>> from FIAS_RU import SPAS
51
+ >>>
52
+ >>> # Вариант 1: Автоматически из переменных окружения
53
+ >>> spas = SPAS() # Использует FIAS_TOKEN из env
54
+ >>>
55
+ >>> # Вариант 2: Явно указать токен
56
+ >>> spas = SPAS(token="your_token")
57
+ >>>
58
+ >>> # Вариант 3: Полная настройка
59
+ >>> spas = SPAS(base_url="https://...", token="...")
60
+ >>>
61
+ >>> # Поиск (автоопределение типа)
62
+ >>> address = spas.search("Москва, Тверская 1")
63
+ >>> address = spas.search("77000000000000000000000") # по GUID
64
+ >>> address = spas.search("77:01:0001001:1") # по кадастру
65
+ >>> address = spas.search(123456) # по ID
66
+ >>>
67
+ >>> # Автокомплит
68
+ >>> hints = spas.autocomplete("Москва, Тв")
69
+ >>>
70
+ >>> # Работа с результатами
71
+ >>> print(address.full_name)
72
+ >>> print(address.postal_code) # Быстрый доступ к деталям
73
+ >>> print(address.oktmo)
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ base_url: Optional[str] = None,
79
+ token: Optional[str] = None,
80
+ timeout: float = 30.0,
81
+ max_retries: int = 3,
82
+ default_address_type: AddressType = AddressType.ADMINISTRATIVE,
83
+ **kwargs
84
+ ):
85
+ """
86
+ Инициализация клиента SPAS
87
+
88
+ Args:
89
+ base_url: URL API (по умолчанию: публичный API ФНС)
90
+ token: Токен авторизации (по умолчанию: из FIAS_TOKEN env)
91
+ timeout: Таймаут запросов в секундах
92
+ max_retries: Количество повторных попыток
93
+ default_address_type: Тип адреса по умолчанию
94
+ **kwargs: Дополнительные параметры (max_connections, rate_limit и т.д.)
95
+
96
+ Raises:
97
+ FIASValidationError: Если токен не указан и не найден в переменных окружения
98
+
99
+ Examples:
100
+ >>> # Минимальная конфигурация
101
+ >>> spas = SPAS() # Токен из FIAS_TOKEN
102
+ >>>
103
+ >>> # С явным токеном
104
+ >>> spas = SPAS(token="your_token_here")
105
+ >>>
106
+ >>> # Полная конфигурация
107
+ >>> spas = SPAS(
108
+ ... base_url="https://custom-api.com",
109
+ ... token="token",
110
+ ... timeout=60,
111
+ ... max_retries=5
112
+ ... )
113
+ """
114
+ # Автоопределение URL
115
+ if base_url is None:
116
+ base_url = os.getenv("FIAS_BASE_URL", DEFAULT_BASE_URL)
117
+
118
+ # Автоопределение токена
119
+ if token is None:
120
+ token = os.getenv("FIAS_TOKEN")
121
+ if not token:
122
+ raise FIASValidationError(
123
+ "Токен не найден! Укажите токен одним из способов:\n"
124
+ "1. SPAS(token='your_token')\n"
125
+ "2. Установите переменную окружения: export FIAS_TOKEN='your_token'\n"
126
+ "3. Создайте .env файл с FIAS_TOKEN=your_token"
127
+ )
128
+
129
+ super().__init__(base_url, timeout, **kwargs)
130
+ self.token = token
131
+ self.max_retries = max_retries
132
+ self.default_address_type = default_address_type
133
+
134
+ logger.info(f"SPAS клиент инициализирован: {base_url}")
135
+
136
+ def _get_headers(self) -> dict:
137
+ """Получить заголовки с токеном"""
138
+ return {
139
+ "accept": "application/json",
140
+ "Content-Type": "application/json",
141
+ "master-token": self.token # ФИАС использует master-token header
142
+ }
143
+
144
+ def _handle_response(self, response, error_prefix: str = "Ошибка API"):
145
+ """Обработка ответа с понятными сообщениями"""
146
+ try:
147
+ response.raise_for_status()
148
+ return response.json()
149
+ except Exception as e:
150
+ status = getattr(response, 'status_code', None)
151
+
152
+ # Красивые сообщения об ошибках
153
+ if status == 403:
154
+ raise FIASAPIError(
155
+ "❌ Доступ запрещён. Проверьте токен авторизации.\n"
156
+ "Получите токен на https://fias.nalog.ru/"
157
+ )
158
+ elif status == 404:
159
+ return None # Не найдено - это нормально
160
+ elif status == 429:
161
+ raise FIASAPIError("⏱️ Превышен лимит запросов. Подождите немного.")
162
+ elif status in (500, 502, 503):
163
+ raise FIASAPIError(f"🔧 Сервер временно недоступен ({status})")
164
+ elif status == 408:
165
+ raise FIASTimeoutError("⏰ Таймаут запроса. Попробуйте увеличить timeout")
166
+ else:
167
+ text = getattr(response, 'text', str(e))[:200]
168
+ raise FIASNetworkError(f"{error_prefix} ({status}): {text}")
169
+
170
+ def _detect_query_type(self, query: Union[str, int]) -> tuple:
171
+ """
172
+ 🧠 Умное определение типа запроса
173
+
174
+ Returns:
175
+ (query_type, normalized_query)
176
+ query_type: 'id' | 'guid' | 'cadastral' | 'string'
177
+ """
178
+ if isinstance(query, int):
179
+ return ('id', query)
180
+
181
+ if not isinstance(query, str):
182
+ raise FIASValidationError(f"Запрос должен быть строкой или числом: {type(query)}")
183
+
184
+ query = query.strip()
185
+
186
+ # GUID
187
+ if GUID_PATTERN.match(query):
188
+ return ('guid', query)
189
+
190
+ # Кадастровый номер
191
+ if CADASTRAL_PATTERN.match(query):
192
+ return ('cadastral', query)
193
+
194
+ # ID (строка с числом)
195
+ if query.isdigit():
196
+ return ('id', int(query))
197
+
198
+ # Обычная строка
199
+ return ('string', query)
200
+
201
+ # =================================================================
202
+ # ГЛАВНЫЕ МЕТОДЫ (максимально простые)
203
+ # =================================================================
204
+
205
+ @auto_retry
206
+ def search(
207
+ self,
208
+ query: Union[str, int],
209
+ address_type: Optional[AddressType] = None
210
+ ) -> Optional[AddressItem]:
211
+ """
212
+ 🔍 Умный поиск - автоматически определяет тип запроса
213
+
214
+ Args:
215
+ query: Что искать (строка, ID, GUID, кадастровый номер)
216
+ address_type: Тип адреса (по умолчанию из конфига)
217
+
218
+ Returns:
219
+ Найденный адрес или None
220
+
221
+ Examples:
222
+ >>> spas = SPAS()
223
+ >>>
224
+ >>> # Поиск по строке
225
+ >>> addr = spas.search("Москва, Тверская 1")
226
+ >>>
227
+ >>> # Поиск по GUID
228
+ >>> addr = spas.search("77000000-0000-0000-0000-000000000000")
229
+ >>>
230
+ >>> # Поиск по кадастровому номеру
231
+ >>> addr = spas.search("77:01:0001001:1")
232
+ >>>
233
+ >>> # Поиск по ID
234
+ >>> addr = spas.search(123456)
235
+ >>> addr = spas.search("123456") # Тоже работает
236
+ """
237
+ query_type, normalized_query = self._detect_query_type(query)
238
+ address_type = address_type or self.default_address_type
239
+
240
+ self.rate_limiter.acquire()
241
+
242
+ try:
243
+ # Роутинг по типу запроса
244
+ if query_type == 'id':
245
+ return self._search_by_id(normalized_query, address_type)
246
+ elif query_type == 'guid':
247
+ return self._search_by_guid(normalized_query, address_type)
248
+ elif query_type == 'cadastral':
249
+ return self._search_by_cadastral(normalized_query, address_type)
250
+ else: # string
251
+ return self._search_by_string(normalized_query, address_type)
252
+
253
+ except Exception as e:
254
+ logger.error(f"Не удалось найти '{query}': {e}")
255
+ raise
256
+
257
+ def _search_by_string(self, query: str, address_type: AddressType) -> Optional[AddressItem]:
258
+ """Поиск по строке"""
259
+ if len(query) < 3:
260
+ raise FIASValidationError("Минимальная длина запроса - 3 символа")
261
+
262
+ response = self.sync_client.get(
263
+ "/api/spas/v2.0/SearchAddressItem",
264
+ params={"search_string": query, "address_type": address_type.value},
265
+ headers=self._get_headers()
266
+ )
267
+ data = self._handle_response(response, f"Ошибка поиска '{query}'")
268
+ return AddressItem(**data) if data else None
269
+
270
+ def _search_by_id(self, object_id: int, address_type: AddressType) -> Optional[AddressItem]:
271
+ """Поиск по ID"""
272
+ response = self.sync_client.get(
273
+ "/api/spas/v2.0/GetAddressItemById",
274
+ params={"object_id": object_id, "address_type": address_type.value},
275
+ headers=self._get_headers()
276
+ )
277
+ data = self._handle_response(response, f"Ошибка поиска ID={object_id}")
278
+ addresses = data.get("addresses", []) if data else []
279
+ return AddressItem(**addresses[0]) if addresses else None
280
+
281
+ def _search_by_guid(self, guid: str, address_type: AddressType) -> Optional[AddressItem]:
282
+ """Поиск по GUID"""
283
+ response = self.sync_client.get(
284
+ "/api/spas/v2.0/GetAddressItemByGuid",
285
+ params={"object_guid": guid, "address_type": address_type.value},
286
+ headers=self._get_headers()
287
+ )
288
+ data = self._handle_response(response, f"Ошибка поиска GUID={guid}")
289
+ addresses = data.get("addresses", []) if data else []
290
+ return AddressItem(**addresses[0]) if addresses else None
291
+
292
+ def _search_by_cadastral(self, cadastral: str, address_type: AddressType) -> Optional[AddressItem]:
293
+ """Поиск по кадастровому номеру"""
294
+ response = self.sync_client.get(
295
+ "/api/spas/v2.0/GetAddressItemByCadastralNumber",
296
+ params={"cadastral_number": cadastral, "address_type": address_type.value},
297
+ headers=self._get_headers()
298
+ )
299
+ data = self._handle_response(response, f"Ошибка поиска кадастра={cadastral}")
300
+ addresses = data.get("addresses", []) if data else []
301
+ return AddressItem(**addresses[0]) if addresses else None
302
+
303
+ @auto_retry
304
+ def autocomplete(
305
+ self,
306
+ partial_address: str,
307
+ limit: int = 10,
308
+ address_type: Optional[AddressType] = None,
309
+ up_to_level: Optional[int] = None
310
+ ) -> List[SearchHint]:
311
+ """
312
+ 💡 Автокомплит адреса (как в Яндекс/Google картах)
313
+
314
+ Args:
315
+ partial_address: Неполный адрес (минимум 1 символ)
316
+ limit: Максимум подсказок (по умолчанию 10)
317
+ address_type: Тип адреса
318
+ up_to_level: До какого уровня искать
319
+
320
+ Returns:
321
+ Список подсказок для автокомплита
322
+
323
+ Examples:
324
+ >>> spas = SPAS()
325
+ >>>
326
+ >>> # Простой автокомплит
327
+ >>> hints = spas.autocomplete("Москва, Тв")
328
+ >>> for hint in hints:
329
+ ... print(hint.full_name)
330
+ >>>
331
+ >>> # С ограничением результатов
332
+ >>> hints = spas.autocomplete("Санкт", limit=5)
333
+ >>>
334
+ >>> # До определённого уровня (например, только улицы)
335
+ >>> hints = spas.autocomplete("Москва, Тверская", up_to_level=7)
336
+ """
337
+ if not partial_address or len(partial_address.strip()) < 1:
338
+ raise FIASValidationError("Минимальная длина запроса - 1 символ")
339
+
340
+ address_type = address_type or self.default_address_type
341
+ self.rate_limiter.acquire()
342
+
343
+ try:
344
+ payload = {
345
+ "searchString": partial_address.strip(),
346
+ "addressType": address_type.value
347
+ }
348
+ if up_to_level is not None:
349
+ payload["upToLevel"] = up_to_level
350
+
351
+ response = self.sync_client.post(
352
+ "/api/spas/v2.0/GetAddressHint",
353
+ json=payload,
354
+ headers=self._get_headers()
355
+ )
356
+ data = self._handle_response(response, f"Ошибка автокомплита '{partial_address}'")
357
+ hints = [SearchHint(**hint) for hint in data.get("hints", [])]
358
+
359
+ return hints[:limit]
360
+ except Exception as e:
361
+ logger.error(f"Автокомплит провалился для '{partial_address}': {e}")
362
+ raise
363
+
364
+ @auto_retry
365
+ def get_regions(self) -> List[AddressItem]:
366
+ """
367
+ 🗺️ Получить все регионы РФ
368
+
369
+ Returns:
370
+ Список всех регионов
371
+
372
+ Example:
373
+ >>> spas = SPAS()
374
+ >>> regions = spas.get_regions()
375
+ >>> for region in regions[:5]:
376
+ ... print(f"{region.region_code}: {region.full_name}")
377
+ """
378
+ self.rate_limiter.acquire()
379
+
380
+ try:
381
+ response = self.sync_client.get(
382
+ "/api/spas/v2.0/GetRegions",
383
+ headers=self._get_headers()
384
+ )
385
+ data = self._handle_response(response, "Ошибка получения регионов")
386
+ return [AddressItem(**addr) for addr in data.get("addresses", [])]
387
+ except Exception as e:
388
+ logger.error(f"Не удалось получить регионы: {e}")
389
+ raise
390
+
391
+ @auto_retry
392
+ def get_details(self, address: Union[AddressItem, int]) -> Optional[AddressDetails]:
393
+ """
394
+ ℹ️ Получить детали адреса (ОКТМО, ИФНС, почтовый индекс и т.д.)
395
+
396
+ Args:
397
+ address: AddressItem или object_id
398
+
399
+ Returns:
400
+ Детали адреса
401
+
402
+ Examples:
403
+ >>> spas = SPAS()
404
+ >>>
405
+ >>> # Вариант 1: Передать AddressItem
406
+ >>> addr = spas.search("Москва, Тверская 1")
407
+ >>> details = spas.get_details(addr)
408
+ >>>
409
+ >>> # Вариант 2: Передать ID напрямую
410
+ >>> details = spas.get_details(123456)
411
+ >>>
412
+ >>> print(details.postal_code)
413
+ >>> print(details.oktmo)
414
+ """
415
+ object_id = address.object_id if isinstance(address, AddressItem) else address
416
+
417
+ if not isinstance(object_id, int) or object_id <= 0:
418
+ raise FIASValidationError(f"ID должен быть положительным числом: {object_id}")
419
+
420
+ self.rate_limiter.acquire()
421
+
422
+ try:
423
+ response = self.sync_client.get(
424
+ "/api/spas/v2.0/GetDetails",
425
+ params={"object_id": object_id},
426
+ headers=self._get_headers()
427
+ )
428
+ data = self._handle_response(response, f"Ошибка получения деталей ID={object_id}")
429
+
430
+ if data and "address_details" in data:
431
+ return AddressDetails(**data["address_details"])
432
+ return None
433
+ except Exception as e:
434
+ logger.error(f"Не удалось получить детали {object_id}: {e}")
435
+ raise
436
+
437
+ # =================================================================
438
+ # УДОБНЫЕ АЛИАСЫ
439
+ # =================================================================
440
+
441
+ def find(self, query: Union[str, int], **kwargs) -> Optional[AddressItem]:
442
+ """Алиас для search()"""
443
+ return self.search(query, **kwargs)
444
+
445
+ def complete(self, partial: str, **kwargs) -> List[SearchHint]:
446
+ """Алиас для autocomplete()"""
447
+ return self.autocomplete(partial, **kwargs)
448
+
449
+ # =================================================================
450
+ # CONTEXT MANAGER
451
+ # =================================================================
452
+
453
+ def __enter__(self):
454
+ return self
455
+
456
+ def __exit__(self, *args):
457
+ self.close()
458
+
459
+ def __repr__(self):
460
+ return f"<SPAS(base_url='{self.base_url}', token='***')>"
@@ -0,0 +1,35 @@
1
+ """
2
+ FIAS_RU/FIAS_RU/SPAS/exceptions.py
3
+
4
+ Кастомные исключения для FIAS SDK
5
+ """
6
+
7
+
8
+ class FIASError(Exception):
9
+ """Базовое исключение для всех ошибок FIAS SDK"""
10
+ pass
11
+
12
+
13
+ class FIASValidationError(FIASError):
14
+ """Ошибка валидации входных данных"""
15
+ pass
16
+
17
+
18
+ class FIASAPIError(FIASError):
19
+ """Ошибка API (5xx, ошибки сервера)"""
20
+ pass
21
+
22
+
23
+ class FIASNetworkError(FIASError):
24
+ """Сетевая ошибка (проблемы с соединением)"""
25
+ pass
26
+
27
+
28
+ class FIASTimeoutError(FIASError):
29
+ """Таймаут запроса"""
30
+ pass
31
+
32
+
33
+ class FIASNotFoundError(FIASError):
34
+ """Объект не найден (404)"""
35
+ pass