amochka 0.3.2__tar.gz → 0.3.3__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.
- {amochka-0.3.2 → amochka-0.3.3}/PKG-INFO +1 -1
- {amochka-0.3.2 → amochka-0.3.3}/amochka/client.py +83 -20
- {amochka-0.3.2 → amochka-0.3.3}/amochka.egg-info/PKG-INFO +1 -1
- {amochka-0.3.2 → amochka-0.3.3}/etl/loaders.py +56 -52
- {amochka-0.3.2 → amochka-0.3.3}/pyproject.toml +1 -1
- {amochka-0.3.2 → amochka-0.3.3}/README.md +0 -0
- {amochka-0.3.2 → amochka-0.3.3}/amochka/__init__.py +0 -0
- {amochka-0.3.2 → amochka-0.3.3}/amochka/etl.py +0 -0
- {amochka-0.3.2 → amochka-0.3.3}/amochka.egg-info/SOURCES.txt +0 -0
- {amochka-0.3.2 → amochka-0.3.3}/amochka.egg-info/dependency_links.txt +0 -0
- {amochka-0.3.2 → amochka-0.3.3}/amochka.egg-info/requires.txt +0 -0
- {amochka-0.3.2 → amochka-0.3.3}/amochka.egg-info/top_level.txt +0 -0
- {amochka-0.3.2 → amochka-0.3.3}/etl/__init__.py +0 -0
- {amochka-0.3.2 → amochka-0.3.3}/etl/config.py +0 -0
- {amochka-0.3.2 → amochka-0.3.3}/etl/extractors.py +0 -0
- {amochka-0.3.2 → amochka-0.3.3}/etl/migrations/001_create_tables.sql +0 -0
- {amochka-0.3.2 → amochka-0.3.3}/etl/run_etl.py +0 -0
- {amochka-0.3.2 → amochka-0.3.3}/etl/transformers.py +0 -0
- {amochka-0.3.2 → amochka-0.3.3}/setup.cfg +0 -0
- {amochka-0.3.2 → amochka-0.3.3}/tests/test_client.py +0 -0
|
@@ -269,6 +269,8 @@ class AmoCRMClient:
|
|
|
269
269
|
log_level=logging.INFO,
|
|
270
270
|
disable_logging=False,
|
|
271
271
|
rate_limit: Optional[int] = None,
|
|
272
|
+
max_retries: int = 3,
|
|
273
|
+
retry_delay: float = 1.0,
|
|
272
274
|
*,
|
|
273
275
|
client_id: Optional[str] = None,
|
|
274
276
|
client_secret: Optional[str] = None,
|
|
@@ -283,9 +285,13 @@ class AmoCRMClient:
|
|
|
283
285
|
:param log_level: Уровень логирования (например, logging.DEBUG, logging.INFO).
|
|
284
286
|
:param disable_logging: Если True, логирование будет отключено.
|
|
285
287
|
:param rate_limit: Максимальное количество запросов в секунду (по умолчанию 7).
|
|
288
|
+
:param max_retries: Максимальное количество повторных попыток при ошибках (по умолчанию 3).
|
|
289
|
+
:param retry_delay: Базовая задержка между попытками в секундах (по умолчанию 1.0).
|
|
286
290
|
"""
|
|
287
291
|
self.base_url = base_url.rstrip('/')
|
|
288
292
|
self.rate_limit = rate_limit if rate_limit is not None else DEFAULT_RATE_LIMIT
|
|
293
|
+
self.max_retries = max_retries
|
|
294
|
+
self.retry_delay = retry_delay
|
|
289
295
|
self._request_times: List[float] = [] # Для отслеживания времени запросов
|
|
290
296
|
domain = self.base_url.split("//")[-1].split(".")[0]
|
|
291
297
|
self.domain = domain
|
|
@@ -402,7 +408,7 @@ class AmoCRMClient:
|
|
|
402
408
|
|
|
403
409
|
def _make_request(self, method, endpoint, params=None, data=None, timeout=10):
|
|
404
410
|
"""
|
|
405
|
-
Выполняет HTTP-запрос к API amoCRM с учетом ограничения по скорости (rate limit).
|
|
411
|
+
Выполняет HTTP-запрос к API amoCRM с учетом ограничения по скорости (rate limit) и retry.
|
|
406
412
|
|
|
407
413
|
:param method: HTTP-метод (GET, PATCH, POST, DELETE и т.д.).
|
|
408
414
|
:param endpoint: Конечная точка API (начинается с /api/v4/).
|
|
@@ -410,31 +416,88 @@ class AmoCRMClient:
|
|
|
410
416
|
:param data: Данные, отправляемые в JSON-формате.
|
|
411
417
|
:param timeout: Тайм‑аут запроса в секундах (по умолчанию 10).
|
|
412
418
|
:return: Ответ в формате JSON или None (если статус 204).
|
|
413
|
-
:raises Exception: При получении кода ошибки, отличного от 200/204.
|
|
419
|
+
:raises Exception: При получении кода ошибки, отличного от 200/204 после всех retry.
|
|
414
420
|
"""
|
|
415
|
-
# Ручной rate limiting
|
|
416
|
-
self._wait_for_rate_limit()
|
|
417
|
-
|
|
418
421
|
url = f"{self.base_url}{endpoint}"
|
|
419
422
|
headers = {
|
|
420
423
|
"Authorization": f"Bearer {self.token}",
|
|
421
424
|
"Content-Type": "application/json"
|
|
422
425
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
426
|
+
|
|
427
|
+
last_exception = None
|
|
428
|
+
retryable_status_codes = {429, 500, 502, 503, 504}
|
|
429
|
+
|
|
430
|
+
for attempt in range(self.max_retries + 1):
|
|
431
|
+
# Ручной rate limiting перед каждой попыткой
|
|
432
|
+
self._wait_for_rate_limit()
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
self.logger.debug(f"Making {method} request to {url} (attempt {attempt + 1})")
|
|
436
|
+
response = requests.request(method, url, headers=headers, params=params, json=data, timeout=timeout)
|
|
437
|
+
|
|
438
|
+
# 401 - пробуем refresh токена (один раз)
|
|
439
|
+
if response.status_code == 401 and self.refresh_token and attempt == 0:
|
|
440
|
+
self.logger.info("Получен 401, пробую обновить токен и повторить запрос…")
|
|
441
|
+
self._refresh_access_token()
|
|
442
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
443
|
+
continue
|
|
444
|
+
|
|
445
|
+
# Успех
|
|
446
|
+
if response.status_code in (200, 204):
|
|
447
|
+
if response.status_code == 204:
|
|
448
|
+
return None
|
|
449
|
+
return response.json()
|
|
450
|
+
|
|
451
|
+
# Retryable ошибки (429, 5xx)
|
|
452
|
+
if response.status_code in retryable_status_codes:
|
|
453
|
+
delay = self._calculate_retry_delay(attempt, response)
|
|
454
|
+
self.logger.warning(
|
|
455
|
+
f"Получен {response.status_code}, retry через {delay:.1f}с (попытка {attempt + 1}/{self.max_retries + 1})"
|
|
456
|
+
)
|
|
457
|
+
last_exception = Exception(f"Ошибка запроса: {response.status_code}, {response.text}")
|
|
458
|
+
if attempt < self.max_retries:
|
|
459
|
+
time.sleep(delay)
|
|
460
|
+
continue
|
|
461
|
+
raise last_exception
|
|
462
|
+
|
|
463
|
+
# Не-retryable ошибка (400, 403, 404 и т.д.)
|
|
464
|
+
self.logger.error(f"Request error {response.status_code}: {response.text}")
|
|
465
|
+
raise Exception(f"Ошибка запроса: {response.status_code}, {response.text}")
|
|
466
|
+
|
|
467
|
+
except requests.exceptions.Timeout as e:
|
|
468
|
+
last_exception = e
|
|
469
|
+
delay = self._calculate_retry_delay(attempt)
|
|
470
|
+
self.logger.warning(f"Timeout, retry через {delay:.1f}с (попытка {attempt + 1}/{self.max_retries + 1})")
|
|
471
|
+
if attempt < self.max_retries:
|
|
472
|
+
time.sleep(delay)
|
|
473
|
+
continue
|
|
474
|
+
raise Exception(f"Timeout после {self.max_retries + 1} попыток: {e}")
|
|
475
|
+
|
|
476
|
+
except requests.exceptions.ConnectionError as e:
|
|
477
|
+
last_exception = e
|
|
478
|
+
delay = self._calculate_retry_delay(attempt)
|
|
479
|
+
self.logger.warning(f"Connection error, retry через {delay:.1f}с (попытка {attempt + 1}/{self.max_retries + 1})")
|
|
480
|
+
if attempt < self.max_retries:
|
|
481
|
+
time.sleep(delay)
|
|
482
|
+
continue
|
|
483
|
+
raise Exception(f"Connection error после {self.max_retries + 1} попыток: {e}")
|
|
484
|
+
|
|
485
|
+
# Если дошли сюда — исчерпали все попытки
|
|
486
|
+
raise last_exception or Exception("Неизвестная ошибка после всех retry")
|
|
487
|
+
|
|
488
|
+
def _calculate_retry_delay(self, attempt: int, response=None) -> float:
|
|
489
|
+
"""Вычисляет задержку перед retry с exponential backoff."""
|
|
490
|
+
# Проверяем Retry-After header (для 429)
|
|
491
|
+
if response is not None:
|
|
492
|
+
retry_after = response.headers.get("Retry-After")
|
|
493
|
+
if retry_after:
|
|
494
|
+
try:
|
|
495
|
+
return float(retry_after)
|
|
496
|
+
except ValueError:
|
|
497
|
+
pass
|
|
498
|
+
|
|
499
|
+
# Exponential backoff: delay * 2^attempt (1, 2, 4, 8...)
|
|
500
|
+
return self.retry_delay * (2 ** attempt)
|
|
438
501
|
|
|
439
502
|
def _refresh_access_token(self):
|
|
440
503
|
"""Обновляет access_token по refresh_token и сохраняет его в token_file."""
|
|
@@ -234,19 +234,23 @@ class PostgresLoader:
|
|
|
234
234
|
(account_id, leads_id),
|
|
235
235
|
)
|
|
236
236
|
|
|
237
|
-
# Вставляем новые
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
237
|
+
# Вставляем новые (batch insert)
|
|
238
|
+
if not attributes:
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
values = [
|
|
242
|
+
(account_id, leads_id, attr["attribute_id"], attr["name"], attr.get("value"))
|
|
243
|
+
for attr in attributes
|
|
244
|
+
]
|
|
245
|
+
cursor.executemany(
|
|
246
|
+
"""
|
|
247
|
+
INSERT INTO amocrm_leads_attributes (account_id, leads_id, attribute_id, name, value)
|
|
248
|
+
VALUES (%s, %s, %s, %s, %s)
|
|
249
|
+
""",
|
|
250
|
+
values,
|
|
251
|
+
)
|
|
248
252
|
|
|
249
|
-
return
|
|
253
|
+
return len(values)
|
|
250
254
|
|
|
251
255
|
def upsert_lead_tags(self, cursor, tags: List[Dict[str, Any]], leads_id: int) -> int:
|
|
252
256
|
"""Вставляет теги сделки (полная замена)."""
|
|
@@ -261,19 +265,20 @@ class PostgresLoader:
|
|
|
261
265
|
(account_id, leads_id),
|
|
262
266
|
)
|
|
263
267
|
|
|
264
|
-
# Вставляем новые
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
268
|
+
# Вставляем новые (batch insert)
|
|
269
|
+
values = [
|
|
270
|
+
(account_id, leads_id, tag["tag_id"], tag["name"])
|
|
271
|
+
for tag in tags
|
|
272
|
+
]
|
|
273
|
+
cursor.executemany(
|
|
274
|
+
"""
|
|
275
|
+
INSERT INTO amocrm_leads_tags (account_id, leads_id, tag_id, name)
|
|
276
|
+
VALUES (%s, %s, %s, %s)
|
|
277
|
+
""",
|
|
278
|
+
values,
|
|
279
|
+
)
|
|
275
280
|
|
|
276
|
-
return
|
|
281
|
+
return len(values)
|
|
277
282
|
|
|
278
283
|
def upsert_lead_contacts(self, cursor, contacts: List[Dict[str, Any]], leads_id: int) -> int:
|
|
279
284
|
"""Вставляет связи сделки с контактами (полная замена)."""
|
|
@@ -288,19 +293,20 @@ class PostgresLoader:
|
|
|
288
293
|
(account_id, leads_id),
|
|
289
294
|
)
|
|
290
295
|
|
|
291
|
-
# Вставляем новые
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
296
|
+
# Вставляем новые (batch insert)
|
|
297
|
+
values = [
|
|
298
|
+
(account_id, leads_id, contact["contacts_id"], contact.get("main", False))
|
|
299
|
+
for contact in contacts
|
|
300
|
+
]
|
|
301
|
+
cursor.executemany(
|
|
302
|
+
"""
|
|
303
|
+
INSERT INTO amocrm_leads_contacts (account_id, leads_id, contacts_id, main)
|
|
304
|
+
VALUES (%s, %s, %s, %s)
|
|
305
|
+
""",
|
|
306
|
+
values,
|
|
307
|
+
)
|
|
302
308
|
|
|
303
|
-
return
|
|
309
|
+
return len(values)
|
|
304
310
|
|
|
305
311
|
def load_transformed_lead(
|
|
306
312
|
self,
|
|
@@ -441,26 +447,24 @@ class PostgresLoader:
|
|
|
441
447
|
),
|
|
442
448
|
)
|
|
443
449
|
|
|
444
|
-
# Атрибуты контакта
|
|
450
|
+
# Атрибуты контакта (batch insert)
|
|
445
451
|
if transformed.attributes:
|
|
452
|
+
account_id = transformed.contact["account_id"]
|
|
446
453
|
cursor.execute(
|
|
447
454
|
"DELETE FROM amocrm_contacts_attributes WHERE account_id = %s AND contacts_id = %s",
|
|
448
|
-
(
|
|
455
|
+
(account_id, contacts_id),
|
|
456
|
+
)
|
|
457
|
+
values = [
|
|
458
|
+
(account_id, contacts_id, attr["attribute_id"], attr["name"], attr.get("value"))
|
|
459
|
+
for attr in transformed.attributes
|
|
460
|
+
]
|
|
461
|
+
cursor.executemany(
|
|
462
|
+
"""
|
|
463
|
+
INSERT INTO amocrm_contacts_attributes (account_id, contacts_id, attribute_id, name, value)
|
|
464
|
+
VALUES (%s, %s, %s, %s, %s)
|
|
465
|
+
""",
|
|
466
|
+
values,
|
|
449
467
|
)
|
|
450
|
-
for attr in transformed.attributes:
|
|
451
|
-
cursor.execute(
|
|
452
|
-
"""
|
|
453
|
-
INSERT INTO amocrm_contacts_attributes (account_id, contacts_id, attribute_id, name, value)
|
|
454
|
-
VALUES (%s, %s, %s, %s, %s)
|
|
455
|
-
""",
|
|
456
|
-
(
|
|
457
|
-
transformed.contact["account_id"],
|
|
458
|
-
contacts_id,
|
|
459
|
-
attr["attribute_id"],
|
|
460
|
-
attr["name"],
|
|
461
|
-
attr.get("value"),
|
|
462
|
-
),
|
|
463
|
-
)
|
|
464
468
|
|
|
465
469
|
return contacts_id
|
|
466
470
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|