Files
iklim-sandbox/common/client.py
T
2026-09-09 16:21:13 +03:00

385 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Reusable authenticated HTTP client for iklim.co APIs."""
from __future__ import annotations
import json
import logging
import re
import time
import uuid
from dataclasses import dataclass
from email.utils import parsedate_to_datetime
from pathlib import Path
from typing import Any, Callable
import requests
from .config import Settings
from .errors import ApiError
from .security import TokenStore, create_signed_headers, token_is_valid
LOGGER = logging.getLogger(__name__)
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
SENSITIVE_KEY_PARTS = {
"apikey",
"authorization",
"cookie",
"password",
"secret",
"signature",
"token",
"username",
}
REDACTED = "<redacted>"
@dataclass(frozen=True)
class ApiResponse:
status_code: int
data: Any
def _error_message(response: requests.Response, data: Any) -> str:
if isinstance(data, dict):
message = data.get("message") or data.get("messages") or data.get("error")
if isinstance(message, list):
return "; ".join(str(item) for item in message)
if message:
return str(message)
return response.reason or "Bilinmeyen API hatası"
def _normalized_key(value: object) -> str:
return "".join(character for character in str(value).lower() if character.isalnum())
def _redact(value: Any, parent_key: str = "") -> Any:
if isinstance(value, dict):
redact_all_values = _normalized_key(parent_key) == "requestheaders"
result = {}
for key, item in value.items():
normalized = _normalized_key(key)
sensitive = redact_all_values or any(
part in normalized for part in SENSITIVE_KEY_PARTS
)
result[key] = REDACTED if sensitive else _redact(item, str(key))
return result
if isinstance(value, list):
return [_redact(item, parent_key) for item in value]
return value
def _redact_text(value: str) -> str:
value = re.sub(r"(?i)Bearer\s+\S+", f"Bearer {REDACTED}", value)
return re.sub(
r"\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b",
REDACTED,
value,
)
def _request_body_for_log(body: str) -> Any:
if not body:
return None
try:
return _redact(json.loads(body))
except json.JSONDecodeError:
return _redact_text(body[:20_000])
def _response_body_for_log(response: requests.Response) -> Any:
if not response.content:
return None
try:
return _redact(response.json())
except requests.exceptions.JSONDecodeError:
return _redact_text(response.text[:20_000])
class AuthenticationManager:
LOGIN_PATH = "/v1/auth/login"
REFRESH_PATH = "/v1/auth/refresh"
def __init__(self, client: "IklimClient", token_store: TokenStore) -> None:
self.client = client
self.token_store = token_store
def ensure_access_token(self) -> str:
access_token = self.token_store.access_token
if not access_token:
LOGGER.info("Access token bulunamadı; otomatik login yapılıyor.")
self.login()
return self._required_saved_access_token()
if token_is_valid(access_token, self.client.settings.token_expiry_skew_seconds):
return access_token
LOGGER.info("Access token süresi dolmuş veya dolmak üzere.")
refresh_token = self.token_store.refresh_token
if token_is_valid(
refresh_token, self.client.settings.token_expiry_skew_seconds
):
self.refresh()
else:
LOGGER.info("Refresh token geçerli değil; otomatik login yapılıyor.")
self.login()
return self._required_saved_access_token()
def recover_after_unauthorized(self) -> None:
refresh_token = self.token_store.refresh_token
if token_is_valid(
refresh_token, self.client.settings.token_expiry_skew_seconds
):
LOGGER.info("HTTP 401 alındı; access token yenileniyor.")
self.refresh()
else:
LOGGER.info("HTTP 401 alındı ve refresh token geçersiz; yeniden login yapılıyor.")
self.login()
def login(self) -> dict[str, Any]:
username, password = self.client.settings.credentials()
LOGGER.info("iklim.co API login işlemi başlatıldı.")
response = self.client.request_json(
"POST",
self.LOGIN_PATH,
json_body={"username": username, "password": password},
authenticated=False,
)
self._save_auth_response(response.data)
LOGGER.info("Login başarılı; tokenlar .env dosyasına kaydedildi.")
return response.data
def refresh(self) -> dict[str, Any]:
refresh_token = self.token_store.refresh_token
if not refresh_token:
LOGGER.info("Refresh token bulunamadı; otomatik login yapılıyor.")
return self.login()
LOGGER.info("Access token refresh işlemi başlatıldı.")
try:
response = self.client.request_json(
"POST",
self.REFRESH_PATH,
json_body={"refreshToken": refresh_token},
authenticated=False,
)
except ApiError as exc:
if exc.status_code in {400, 401, 403}:
LOGGER.info("Refresh token API tarafından reddedildi; yeniden login yapılıyor.")
return self.login()
raise
self._save_auth_response(response.data)
LOGGER.info("Token yenileme başarılı; yeni tokenlar .env dosyasına kaydedildi.")
return response.data
def _save_auth_response(self, data: Any) -> None:
if not isinstance(data, dict):
raise ApiError("Kimlik doğrulama endpoint'i beklenen JSON nesnesini döndürmedi.")
self.token_store.save(data)
def _required_saved_access_token(self) -> str:
access_token = self.token_store.access_token
if not access_token:
raise ApiError("Login/refresh sonrasında access token kaydedilemedi.")
return access_token
class IklimClient:
def __init__(
self,
settings: Settings,
*,
session: requests.Session | None = None,
sleep: Callable[[float], None] = time.sleep,
) -> None:
self.settings = settings
self.session = session or requests.Session()
self.sleep = sleep
self.token_store = TokenStore(settings.env_file)
self.auth = AuthenticationManager(self, self.token_store)
@classmethod
def from_env(cls, env_file: Path | str | None = None) -> "IklimClient":
settings = Settings.from_env() if env_file is None else Settings.from_env(env_file)
return cls(settings)
def request_json(
self,
method: str,
path_with_query: str,
*,
json_body: Any = None,
authenticated: bool = True,
idempotency_key: str | None = None,
) -> ApiResponse:
method = method.upper()
if not path_with_query.startswith("/"):
raise ValueError("API yolu '/' ile başlamalıdır.")
body = (
""
if json_body is None
else json.dumps(json_body, ensure_ascii=False, separators=(",", ":"))
)
operation_idempotency_key = idempotency_key or str(uuid.uuid4())
response: requests.Response | None = None
for auth_round in range(2):
access_token = self.auth.ensure_access_token() if authenticated else None
response = self._send_with_retries(
method=method,
path_with_query=path_with_query,
body=body,
access_token=access_token,
idempotency_key=operation_idempotency_key,
)
if response.status_code != 401 or not authenticated:
break
if auth_round == 0:
self.auth.recover_after_unauthorized()
if response is None:
raise ApiError("API isteği için yanıt alınamadı.")
data = self._decode_response(response)
if not response.ok:
raise ApiError(
f"HTTP {response.status_code}: {_error_message(response, data)}",
status_code=response.status_code,
response_data=data,
)
return ApiResponse(response.status_code, data)
def _send_with_retries(
self,
*,
method: str,
path_with_query: str,
body: str,
access_token: str | None,
idempotency_key: str,
) -> requests.Response:
for attempt in range(self.settings.max_retries + 1):
headers = create_signed_headers(
method=method,
path_with_query=path_with_query,
body=body,
secret=self.settings.hmac_secret,
accept_language=self.settings.accept_language,
idempotency_key=idempotency_key,
)
if access_token:
headers["Authorization"] = f"Bearer {access_token}"
url = f"{self.settings.base_url}{path_with_query}"
LOGGER.debug(
"HTTP request | %s",
json.dumps(
{
"attempt": attempt + 1,
"method": method,
"url": url,
"headers": _redact(headers),
"body": _request_body_for_log(body),
},
ensure_ascii=False,
default=str,
),
)
try:
response = self.session.request(
method,
url,
data=None if not body else body.encode("utf-8"),
headers=headers,
timeout=self.settings.timeout_seconds,
)
except requests.RequestException as exc:
LOGGER.debug(
"HTTP network error | %s",
json.dumps(
{
"attempt": attempt + 1,
"method": method,
"url": url,
"error": _redact_text(str(exc)),
},
ensure_ascii=False,
),
)
if attempt >= self.settings.max_retries:
raise ApiError(
f"Ağ hatası; maksimum deneme sayısına ulaşıldı: {exc}"
) from exc
self._wait_before_retry(attempt, None)
continue
LOGGER.debug(
"HTTP response | %s",
json.dumps(
{
"attempt": attempt + 1,
"method": method,
"url": url,
"status": response.status_code,
"headers": _redact(dict(response.headers)),
"body": _response_body_for_log(response),
},
ensure_ascii=False,
default=str,
),
)
if (
response.status_code in RETRYABLE_STATUS_CODES
and attempt < self.settings.max_retries
):
self._wait_before_retry(attempt, response)
continue
return response
raise ApiError("API isteği beklenmeyen biçimde tamamlanamadı.")
def _wait_before_retry(
self, attempt: int, response: requests.Response | None
) -> None:
delay = self.settings.retry_base_seconds * (2**attempt)
if response is not None and response.status_code == 429:
delay = self._retry_after_seconds(response, delay)
LOGGER.warning(
"İstek yeniden denenecek (%s/%s); %.1f saniye bekleniyor.",
attempt + 1,
self.settings.max_retries,
delay,
)
self.sleep(delay)
@staticmethod
def _retry_after_seconds(response: requests.Response, fallback: float) -> float:
raw_value = response.headers.get("Retry-After", "").strip()
if not raw_value:
return fallback
try:
return max(0.0, float(raw_value))
except ValueError:
try:
retry_at = parsedate_to_datetime(raw_value)
return max(0.0, retry_at.timestamp() - time.time())
except (TypeError, ValueError, OverflowError):
return fallback
@staticmethod
def _decode_response(response: requests.Response) -> Any:
if not response.content:
return None
try:
return response.json()
except requests.exceptions.JSONDecodeError as exc:
raise ApiError(
f"API JSON olmayan bir yanıt döndürdü (HTTP {response.status_code}).",
status_code=response.status_code,
) from exc