"""
Cliente HTTP robusto y reutilizable para el SDK de Datadis.
Este módulo proporciona una clase ``HTTPClient`` especializada para realizar peticiones
HTTP a la API de Datadis con funcionalidades avanzadas como reintentos automáticos,
manejo de timeouts, gestión de headers y procesamiento de respuestas.
La clase está optimizada específicamente para las características de la API de Datadis:
- Timeouts largos debido a la lentitud característica del servicio
- Reintentos automáticos con backoff exponencial para manejar inestabilidad
- Soporte para autenticación Bearer Token
- Manejo especial de diferentes tipos de contenido (JSON, form-data, texto plano)
- Procesamiento de respuestas con normalización de texto para caracteres especiales
Características principales:
- **Gestión automática de reintentos**: Backoff exponencial para timeouts y errores de red
- **Flexibilidad de contenido**: Soporte para JSON y form-data según el endpoint
- **Manejo robusto de errores**: Clasificación inteligente de errores HTTP
- **Integración con Pydantic**: Preparado para validación de datos
- **Rate limiting integrado**: Delays automáticos para evitar sobrecarga del servidor
Example:
Uso básico del cliente HTTP::
from datadis_python.utils.http import HTTPClient
# Inicializar con configuración personalizada
client = HTTPClient(timeout=120, retries=5)
# Petición GET simple
response = client.make_request(
method="GET",
url="https://api.example.com/data",
params={"param1": "value1"}
)
# Petición POST con autenticación
client.set_auth_header("bearer_token_here")
response = client.make_request(
method="POST",
url="https://api.example.com/auth",
data={"username": "user", "password": "pass"},
use_form_data=True
)
# Cerrar recursos
client.close()
Uso como context manager (recomendado)::
with HTTPClient(timeout=90, retries=3) as client:
client.set_auth_header("token")
response = client.make_request("GET", "https://api.example.com/data")
# El cliente se cierra automáticamente
Warning:
Este cliente está específicamente optimizado para la API de Datadis. Para uso
general, considere usar directamente la biblioteca ``requests`` o un cliente
HTTP más genérico.
:author: TacoronteRiveroCristian
"""
import time
from typing import Any, Dict, Optional, Union
import requests
from ..exceptions import APIError, AuthenticationError, DatadisError
[documentos]
class HTTPClient:
"""
Cliente HTTP robusto especializado para la API de Datadis.
Esta clase proporciona una interfaz de alto nivel para realizar peticiones HTTP
con características específicamente optimizadas para interactuar con la API de Datadis.
Incluye manejo automático de reintentos, timeouts largos, gestión de autenticación
y procesamiento especializado de respuestas.
Optimizaciones para Datadis:
- **Timeouts largos**: Por defecto 60s, recomendado 90-120s para Datadis
- **Reintentos robustos**: Backoff exponencial con hasta 5 reintentos
- **Rate limiting**: Delays automáticos para evitar sobrecarga del servidor
- **Manejo de encoding**: Desactiva compresión gzip para evitar problemas
- **Procesamiento de texto**: Normalización automática de caracteres especiales
Estrategia de reintentos:
- **Errores de red y timeouts**: Se reintentan automáticamente
- **Errores HTTP 4xx/5xx**: Se propagan inmediatamente (no reintentos)
- **Backoff exponencial**: 2s → 4s → 8s → 16s → 32s (máximo 10s para timeouts)
- **Error 401**: Se propaga para permitir renovación de token
Example:
Configuración típica para Datadis::
# Configuración robusta para API lenta de Datadis
client = HTTPClient(timeout=120, retries=5)
# Autenticación Bearer Token
client.set_auth_header("jwt_token_from_datadis")
# Petición con gestión automática de errores
response = client.make_request(
method="GET",
url="https://datadis.es/api-private/api/get-supplies",
params={"distributor_code": "2"}
)
Diferentes tipos de contenido::
# Para autenticación (form-data)
auth_response = client.make_request(
method="POST",
url="https://datadis.es/nikola-auth/tokens/login",
data={"username": "12345678A", "password": "password"},
use_form_data=True
)
# Para endpoints de datos (JSON, por defecto)
data_response = client.make_request(
method="GET",
url="https://datadis.es/api-private/api/get-consumption-data",
params={"cups": "ES001234567890123456AB"}
)
:param timeout: Timeout para peticiones HTTP en segundos. Recomendado: 90-120s para Datadis
:type timeout: int
:param retries: Número máximo de reintentos automáticos para errores de red/timeouts
:type retries: int
.. note::
La API de Datadis puede ser muy lenta (60-90 segundos) al procesar consultas
complejas que requieren agregar datos de múltiples distribuidoras eléctricas.
.. seealso::
- :class:`SimpleDatadisClientV1` y :class:`SimpleDatadisClientV2` para uso de alto nivel
- Documentación oficial de Datadis para límites de rate limiting
"""
[documentos]
def __init__(self, timeout: int = 60, retries: int = 3):
"""
Inicializa el cliente HTTP con configuración optimizada para Datadis.
Configura una sesión HTTP persistente con headers optimizados y timeouts
apropiados para la API de Datadis. La configuración por defecto está pensada
para un uso general, pero se recomienda ajustar los valores para Datadis.
Configuración recomendada para Datadis:
- timeout: 90-120 segundos (la API puede ser muy lenta)
- retries: 3-5 reintentos (para manejar inestabilidad ocasional)
:param timeout: Timeout para peticiones HTTP en segundos.
Para Datadis se recomienda 90-120s debido a la lentitud del servicio
:type timeout: int
:param retries: Número máximo de reintentos automáticos para errores de red.
3-5 reintentos recomendados para Datadis por su inestabilidad ocasional
:type retries: int
Example:
Configuraciones típicas::
# Configuración conservadora (rápida, pocos reintentos)
client = HTTPClient(timeout=60, retries=2)
# Configuración robusta para Datadis (recomendada)
client = HTTPClient(timeout=120, retries=5)
# Configuración para desarrollo/testing (muy paciente)
client = HTTPClient(timeout=180, retries=3)
"""
self.timeout = timeout
self.retries = retries
self.session = requests.Session()
# Headers optimizados para Datadis
# IMPORTANTE: Desactivar compresión gzip para evitar problemas con algunos endpoints
self.session.headers.update(
{
"User-Agent": "datadis-python-sdk/0.1.0",
"Content-Type": "application/json",
"Accept": "application/json",
"Accept-Encoding": "identity", # Desactivar compresión para evitar problemas
}
)
[documentos]
def make_request(
self,
method: str,
url: str,
data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
use_form_data: bool = False,
) -> Union[Dict[str, Any], str, list]:
"""
Realiza una petición HTTP robusta con reintentos automáticos y manejo de errores.
Este método es el núcleo del cliente HTTP y maneja toda la lógica de peticiones
incluyendo reintentos con backoff exponencial, manejo de diferentes tipos de
contenido, rate limiting automático y procesamiento especializado de respuestas.
Flujo de operación:
1. **Rate limiting**: Delay automático de 0.1s (excepto para autenticación)
2. **Configuración de headers**: Combina headers por defecto con personalizados
3. **Selección de formato**: JSON o form-data según ``use_form_data``
4. **Ejecución con reintentos**: Hasta ``self.retries`` intentos con backoff exponencial
5. **Procesamiento de respuesta**: Manejo especializado según tipo de contenido
Estrategia de reintentos:
- **Errores de red/timeout**: Reintentos con backoff: 2s → 4s → 8s → 16s...
- **Errores HTTP**: Propagación inmediata sin reintentos
- **Máximo wait**: 10 segundos entre reintentos para evitar timeouts excesivos
Tipos de contenido soportados:
- **JSON** (por defecto): Para la mayoría de endpoints de datos
- **Form-data**: Para autenticación y algunos endpoints legacy
- **Detección automática**: Basada en el parámetro ``use_form_data``
:param method: Método HTTP a usar (GET, POST, PUT, DELETE, etc.)
:type method: str
:param url: URL completa del endpoint a consultar
:type url: str
:param data: Datos a enviar en el cuerpo de la petición.
Para GET se ignora, para POST se usa según ``use_form_data``
:type data: Optional[Dict[str, Any]]
:param params: Parámetros de query string a añadir a la URL
:type params: Optional[Dict[str, Any]]
:param headers: Headers HTTP adicionales a combinar con los por defecto.
Los headers personalizados tienen prioridad sobre los por defecto
:type headers: Optional[Dict[str, str]]
:param use_form_data: Si ``True``, envía datos como application/x-www-form-urlencoded.
Si ``False`` (por defecto), envía como application/json
:type use_form_data: bool
:return: Respuesta procesada del servidor. El tipo depende del endpoint:
- **JWT tokens**: ``str`` (para endpoints de autenticación)
- **Datos JSON**: ``Dict[str, Any]`` o ``List[Any]`` (para endpoints de datos)
- **Respuestas de texto**: ``str`` (para endpoints que no devuelven JSON)
:rtype: Union[Dict[str, Any], str, list]
:raises DatadisError: Si se agotan todos los reintentos por errores de red/timeouts
:raises AuthenticationError: Si hay errores de autenticación (401)
:raises APIError: Si la API devuelve errores HTTP (400, 403, 404, 500, etc.)
Example:
Diferentes tipos de peticiones::
# GET con parámetros de query
response = client.make_request(
method="GET",
url="https://datadis.es/api-private/api/get-supplies",
params={"distributor_code": "2", "authorized_nif": "12345678A"}
)
# POST con autenticación (form-data)
token = client.make_request(
method="POST",
url="https://datadis.es/nikola-auth/tokens/login",
data={"username": "12345678A", "password": "mi_password"},
use_form_data=True
)
# GET con headers personalizados
response = client.make_request(
method="GET",
url="https://datadis.es/api-private/api/get-consumption-data",
params={"cups": "ES001234567890123456AB"},
headers={"Authorization": f"Bearer {token}"}
)
Note:
El rate limiting automático (delay de 0.1s) se aplica a todas las peticiones
excepto las de autenticación para evitar sobrecargar los servidores de Datadis
que pueden tener límites de rate restrictivos.
.. seealso::
- :meth:`_handle_response` para detalles del procesamiento de respuestas
- La normalización de texto se realiza automáticamente en respuestas JSON
"""
# Rate limiting automático para evitar sobrecargar el servidor de Datadis
# Excepción: endpoints de autenticación no necesitan delay
if "/nikola-auth" not in url:
time.sleep(0.1) # Delay reducido pero efectivo
# Intentar la petición con reintentos automáticos
for attempt in range(self.retries + 1):
try:
# Configurar headers específicos para esta petición
if headers:
request_headers = {**self.session.headers, **headers}
else:
request_headers = dict(self.session.headers)
# Ejecutar petición según el tipo de datos requerido
if use_form_data and data:
# Para autenticación usar form-data (Content-Type: application/x-www-form-urlencoded)
response = requests.request(
method=method,
url=url,
data=data, # Datos como formulario
params=params,
headers=request_headers,
timeout=self.timeout,
)
else:
# Para peticiones normales usar JSON (Content-Type: application/json)
response = self.session.request(
method=method,
url=url,
json=data, # Datos como JSON
params=params,
timeout=self.timeout,
)
# Procesar respuesta y retornar resultado
return self._handle_response(response, url)
except requests.RequestException as e:
# Si es el último intento, propagar el error
if attempt == self.retries:
raise DatadisError(
f"Error de conexión después de {self.retries + 1} intentos: {str(e)}"
)
# Calcular tiempo de espera con backoff exponencial (máximo 10s)
wait_time = min(10, (2**attempt) * 2)
print(
f"⚠️ Intento {attempt + 1}/{self.retries + 1} falló. "
f"Reintentando en {wait_time}s... (Error: {str(e)})"
)
time.sleep(wait_time)
# Este punto nunca debería alcanzarse debido a la lógica de reintentos,
# pero se incluye para satisfacer el análisis estático de tipos
raise DatadisError(
"Error inesperado: se agotaron todos los reintentos sin lanzar excepción"
)
def _handle_response(
self, response: requests.Response, url: str
) -> Union[Dict[str, Any], str, list]:
"""
Procesa y maneja respuestas HTTP de la API de Datadis con lógica especializada.
Este método interno procesa las respuestas HTTP aplicando lógica específica
para diferentes tipos de endpoints de Datadis. Maneja tanto respuestas exitosas
como errores, aplicando normalización de texto y conversión de tipos según sea necesario.
Lógica de procesamiento por tipo de endpoint:
- **Autenticación** (``/nikola-auth``): Retorna JWT token como string
- **Datos JSON**: Parsea JSON y aplica normalización de caracteres especiales
- **Respuestas de texto**: Retorna contenido raw como string
- **Respuestas vacías**: Maneja respuestas sin contenido adecuadamente
Manejo de errores HTTP:
- **200 OK**: Procesamiento normal según tipo de contenido
- **401 Unauthorized**: Error de autenticación (token inválido/expirado)
- **429 Too Many Requests**: Rate limiting excedido
- **4xx/5xx**: Otros errores HTTP con extracción de mensaje detallado
Normalización de caracteres:
Las respuestas JSON se procesan automáticamente para corregir problemas
de encoding que son comunes en la API de Datadis (caracteres especiales
españoles como ñ, ç, acentos, etc.).
:param response: Objeto Response de requests con la respuesta HTTP
:type response: requests.Response
:param url: URL original de la petición (para contexto en logs/errores)
:type url: str
:return: Respuesta procesada según el tipo de endpoint:
- **Token JWT**: ``str`` para endpoints de autenticación
- **Datos estructurados**: ``Dict[str, Any]`` o ``List[Any]`` para datos
- **Contenido de texto**: ``str`` para respuestas no-JSON
:rtype: Union[Dict[str, Any], str, list]
:raises AuthenticationError: Para errores 401 (credenciales inválidas/token expirado)
:raises APIError: Para errores HTTP 4xx/5xx con código y mensaje detallado
Example:
Diferentes tipos de respuestas procesadas::
# Autenticación → JWT token como string
token_response = client._handle_response(auth_response, "/nikola-auth/tokens/login")
# Resultado: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
# Datos JSON → Dict/List normalizado
supplies_response = client._handle_response(data_response, "/get-supplies")
# Resultado: [{"cups": "ES001...", "address": "CALLE EJEMPLO"}, ...]
# Error HTTP → Exception con detalles
try:
client._handle_response(error_response, "/invalid-endpoint")
except APIError as e:
print(f"Error {e.status_code}: {e.message}")
Note:
Este método es interno y normalmente no se llama directamente. Es invocado
automáticamente por :meth:`make_request` para procesar todas las respuestas.
.. seealso::
- :func:`datadis_python.utils.text_utils.normalize_api_response` para normalización de texto
- Documentación de la API de Datadis para códigos de error específicos
"""
if response.status_code == 200:
# Procesamiento exitoso - determinar tipo de respuesta
if "/nikola-auth" in url:
# Endpoints de autenticación retornan JWT token como texto plano
return response.text.strip()
# Para otros endpoints, intentar parsear como JSON
try:
json_response = response.json()
# Aplicar normalización automática de caracteres especiales
# Esta normalización es crucial para Datadis debido a problemas comunes
# con caracteres españoles (ñ, acentos, ç, etc.)
from ..utils.text_utils import normalize_api_response
return normalize_api_response(json_response)
except ValueError:
# Si no es JSON válido, retornar como texto plano
# Esto puede ocurrir en algunos endpoints legacy o en errores específicos
return response.text
elif response.status_code == 401:
# Error de autenticación - credenciales inválidas o token expirado
raise AuthenticationError(
"Credenciales inválidas o token expirado. "
"Verifique sus credenciales o reautentíquese."
)
elif response.status_code == 429:
# Rate limiting - demasiadas peticiones
raise APIError(
"Límite de peticiones excedido. Reduzca la frecuencia de consultas.",
429,
)
else:
# Otros errores HTTP - extraer mensaje detallado si está disponible
error_msg = f"Error HTTP {response.status_code}"
try:
# Intentar extraer mensaje de error del JSON de respuesta
error_data = response.json()
if "message" in error_data:
error_msg = error_data["message"]
elif "error" in error_data:
error_msg = error_data["error"]
elif "description" in error_data:
error_msg = error_data["description"]
except ValueError:
# Si no es JSON, usar el texto de respuesta si está disponible
if response.text:
error_msg = response.text[
:200
] # Limitar longitud para evitar logs excesivos
# Lanzar error con código de estado y mensaje detallado
raise APIError(error_msg, response.status_code)
[documentos]
def close(self) -> None:
"""
Cierra la sesión HTTP y libera recursos asociados.
Cierra explícitamente la sesión HTTP subyacente, liberando conexiones
de red y otros recursos. Es una buena práctica llamar este método
cuando se termina de usar el cliente, especialmente en aplicaciones
de larga duración.
Este método es seguro de llamar múltiples veces y no genera errores
si la sesión ya está cerrada.
Example:
Uso manual de cierre::
client = HTTPClient(timeout=120, retries=5)
try:
# Usar cliente para peticiones...
response = client.make_request("GET", "https://example.com")
finally:
# Asegurar limpieza de recursos
client.close()
Uso como context manager (recomendado)::
with HTTPClient(timeout=120, retries=5) as client:
# Usar cliente...
response = client.make_request("GET", "https://example.com")
# client.close() se llama automáticamente
Note:
Cuando se usa el cliente como context manager (``with`` statement),
este método se llama automáticamente al salir del bloque, por lo
que no es necesario llamarlo manualmente.
"""
if self.session:
self.session.close()
[documentos]
def __enter__(self):
"""
Método de entrada para context manager.
Permite usar el HTTPClient con la declaración ``with`` de Python para
gestión automática de recursos. Al entrar en el bloque ``with``, retorna
la instancia del cliente lista para usar.
:return: La instancia del cliente HTTP configurada
:rtype: HTTPClient
Example:
Uso como context manager::
with HTTPClient(timeout=120, retries=5) as client:
# Configurar autenticación
token = client.make_request(
"POST", "/auth",
data={"user": "12345678A", "pass": "password"},
use_form_data=True
)
client.set_auth_header(token)
# Realizar peticiones
data = client.make_request("GET", "/api/data")
# client.close() se llama automáticamente aquí
Note:
El uso como context manager es la forma recomendada de usar HTTPClient
ya que garantiza la liberación adecuada de recursos de red.
"""
return self
[documentos]
def __exit__(self, exc_type, exc_val, exc_tb):
"""
Método de salida para context manager.
Se llama automáticamente al salir del bloque ``with``, garantizando que
los recursos del cliente HTTP se liberen adecuadamente, independientemente
de si el bloque se completó exitosamente o se produjo una excepción.
:param exc_type: Tipo de excepción si ocurrió una excepción, None en caso contrario
:param exc_val: Valor de la excepción si ocurrió una excepción, None en caso contrario
:param exc_tb: Traceback de la excepción si ocurrió una excepción, None en caso contrario
Note:
Este método siempre retorna None, lo que significa que no suprime ninguna
excepción que pueda haber ocurrido dentro del bloque ``with``. Las excepciones
se propagan normalmente después de la limpieza de recursos.
"""
self.close()