Skip to content

Commit

Permalink
Added request timeout configuration
Browse files Browse the repository at this point in the history
  • Loading branch information
PPeitsch committed Nov 6, 2024
1 parent bfcad39 commit 19991b0
Show file tree
Hide file tree
Showing 4 changed files with 152 additions and 10 deletions.
2 changes: 2 additions & 0 deletions src/bcra_connector/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from ._version import __version__, version_info
from .bcra_connector import BCRAConnector, BCRAApiError
from .rate_limiter import RateLimitConfig
from .timeout_config import TimeoutConfig
from .principales_variables import (
PrincipalesVariables,
DatosVariable,
Expand Down Expand Up @@ -32,6 +33,7 @@
"BCRAConnector",
"BCRAApiError",
"RateLimitConfig",
"TimeoutConfig",
# Principales Variables
"PrincipalesVariables",
"DatosVariable",
Expand Down
45 changes: 35 additions & 10 deletions src/bcra_connector/bcra_connector.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import requests
from typing import List, Dict, Any, Optional
from typing import List, Dict, Union, Any, Optional
from datetime import datetime, timedelta
import logging
import time
Expand All @@ -10,6 +10,7 @@
from .cheques import Entidad, Cheque, ChequeDetalle
from .estadisticas_cambiarias import Divisa, CotizacionFecha, CotizacionDetalle
from .rate_limiter import RateLimiter, RateLimitConfig
from .timeout_config import TimeoutConfig


class BCRAApiError(Exception):
Expand All @@ -29,24 +30,28 @@ class BCRAConnector:
MAX_RETRIES = 3
RETRY_DELAY = 1 # seconds
DEFAULT_RATE_LIMIT = RateLimitConfig(
calls=10, # 10 llamadas
period=1.0, # por segundo
burst=20 # permitir ráfagas de hasta 20 llamadas
calls=10, # 10 calls
period=1.0, # per second
burst=20 # allowing up to 20 calls
)
DEFAULT_TIMEOUT = TimeoutConfig.default()

def __init__(
self,
language: str = "es-AR",
verify_ssl: bool = True,
debug: bool = False,
rate_limit: Optional[RateLimitConfig] = None
rate_limit: Optional[RateLimitConfig] = None,
timeout: Optional[Union[TimeoutConfig, float]] = None
):
"""Initialize the BCRAConnector.
:param language: The language for API responses, defaults to "es-AR"
:param verify_ssl: Whether to verify SSL certificates, defaults to True
:param debug: Whether to enable debug logging, defaults to False
:param rate_limit: Rate limiting configuration, defaults to DEFAULT_RATE_LIMIT
:param timeout: Request timeout configuration, can be TimeoutConfig or float,
defaults to DEFAULT_TIMEOUT
"""
self.session = requests.Session()
self.session.headers.update({
Expand All @@ -55,6 +60,14 @@ def __init__(
})
self.verify_ssl = verify_ssl

# Configure timeouts
if isinstance(timeout, (int, float)):
self.timeout = TimeoutConfig.from_total(float(timeout))
elif isinstance(timeout, TimeoutConfig):
self.timeout = timeout
else:
self.timeout = self.DEFAULT_TIMEOUT

# Initialize rate limiter
self.rate_limiter = RateLimiter(rate_limit or self.DEFAULT_RATE_LIMIT)

Expand Down Expand Up @@ -85,10 +98,20 @@ def _make_request(self, endpoint: str, params: Optional[Dict[str, Any]] = None)
self.logger.debug(f"Rate limit applied. Waited {delay:.2f} seconds")

self.logger.debug(f"Making request to {url}")
response = self.session.get(url, params=params, verify=self.verify_ssl)
response = self.session.get(
url,
params=params,
verify=self.verify_ssl,
timeout=self.timeout.as_tuple
)
response.raise_for_status()
self.logger.debug("Request successful")
return response.json()
except requests.Timeout as e:
self.logger.error(f"Request timed out (attempt {attempt + 1}/{self.MAX_RETRIES}): {str(e)}")
if attempt == self.MAX_RETRIES - 1:
raise BCRAApiError(f"Request timed out after {self.MAX_RETRIES} attempts") from e
time.sleep(self.RETRY_DELAY * (2 ** attempt))
except requests.RequestException as e:
self.logger.warning(f"Request failed (attempt {attempt + 1}/{self.MAX_RETRIES}): {str(e)}")
if attempt == self.MAX_RETRIES - 1:
Expand Down Expand Up @@ -256,7 +279,7 @@ def get_cotizaciones(self, fecha: Optional[str] = None) -> CotizacionFecha:

def get_evolucion_moneda(self, moneda: str, fecha_desde: Optional[str] = None,
fecha_hasta: Optional[str] = None, limit: int = 1000, offset: int = 0) -> List[
CotizacionFecha]:
CotizacionFecha]:
"""
Fetch the evolution of a specific currency's quotation.
Expand Down Expand Up @@ -361,7 +384,8 @@ def get_latest_quotations(self) -> Dict[str, float]:
cotizaciones = self.get_cotizaciones()
return {detail.codigo_moneda: detail.tipo_cotizacion for detail in cotizaciones.detalle}

def get_currency_pair_evolution(self, base_currency: str, quote_currency: str, days: int = 30) -> List[Dict[str, Any]]:
def get_currency_pair_evolution(self, base_currency: str, quote_currency: str, days: int = 30) -> List[
Dict[str, Any]]:
"""
Get the evolution of a currency pair for the last n days.
Expand All @@ -374,7 +398,8 @@ def get_currency_pair_evolution(self, base_currency: str, quote_currency: str, d
quote_evolution = self.get_currency_evolution(quote_currency, days)

base_dict = {cf.fecha: self._get_cotizacion_detalle(cf, base_currency).tipo_cotizacion for cf in base_evolution}
quote_dict = {cf.fecha: self._get_cotizacion_detalle(cf, quote_currency).tipo_cotizacion for cf in quote_evolution}
quote_dict = {cf.fecha: self._get_cotizacion_detalle(cf, quote_currency).tipo_cotizacion for cf in
quote_evolution}

pair_evolution = []
for date in set(base_dict.keys()) & set(quote_dict.keys()):
Expand Down Expand Up @@ -476,4 +501,4 @@ def generate_variable_report(self, variable_name: str, days: int = 30) -> Dict[s
if hasattr(variable, 'unidad'):
report["unit"] = variable.unidad

return report
return report
55 changes: 55 additions & 0 deletions src/bcra_connector/timeout_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Timeout configuration for API requests."""

from dataclasses import dataclass
from typing import Union, Tuple


@dataclass
class TimeoutConfig:
"""Configuration for request timeouts.
:param connect: How long to wait for the connection to be established (seconds)
:param read: How long to wait for the server to send data (seconds)
"""
connect: float = 3.05 # Default connect timeout
read: float = 27.0 # Default read timeout

def __post_init__(self):
"""Validate timeout values."""
if self.connect <= 0:
raise ValueError("connect timeout must be greater than 0")
if self.read <= 0:
raise ValueError("read timeout must be greater than 0")

@property
def as_tuple(self) -> Tuple[float, float]:
"""Get timeout configuration as a tuple.
:return: Tuple of (connect_timeout, read_timeout)
"""
return (self.connect, self.read)

@classmethod
def from_total(cls, total: float) -> 'TimeoutConfig':
"""Create a TimeoutConfig from a total timeout value.
:param total: Total timeout in seconds, will be split between connect and read
:return: TimeoutConfig instance
:raises ValueError: If total timeout is less than or equal to 0
"""
if total <= 0:
raise ValueError("total timeout must be greater than 0")
# Allocate 10% to connect timeout and 90% to read timeout
return cls(connect=total * 0.1, read=total * 0.9)

@classmethod
def default(cls) -> 'TimeoutConfig':
"""Get the default timeout configuration.
:return: TimeoutConfig instance with default values
"""
return cls()

def __str__(self) -> str:
"""Get string representation of timeout configuration."""
return f"TimeoutConfig(connect={self.connect:.2f}s, read={self.read:.2f}s)"
60 changes: 60 additions & 0 deletions tests/timeout_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Tests for timeout configuration."""

import unittest
from bcra_connector.timeout_config import TimeoutConfig


class TestTimeoutConfig(unittest.TestCase):
"""Test suite for the TimeoutConfig class."""

def test_default_values(self):
"""Test default timeout values."""
config = TimeoutConfig()
self.assertEqual(config.connect, 3.05)
self.assertEqual(config.read, 27.0)

def test_custom_values(self):
"""Test custom timeout values."""
config = TimeoutConfig(connect=5.0, read=30.0)
self.assertEqual(config.connect, 5.0)
self.assertEqual(config.read, 30.0)

def test_invalid_values(self):
"""Test invalid timeout values."""
with self.assertRaises(ValueError):
TimeoutConfig(connect=0)
with self.assertRaises(ValueError):
TimeoutConfig(connect=-1)
with self.assertRaises(ValueError):
TimeoutConfig(read=0)
with self.assertRaises(ValueError):
TimeoutConfig(read=-1)

def test_from_total(self):
"""Test creating TimeoutConfig from total timeout."""
config = TimeoutConfig.from_total(10.0)
self.assertEqual(config.connect, 1.0) # 10% of total
self.assertEqual(config.read, 9.0) # 90% of total

def test_invalid_total(self):
"""Test invalid total timeout values."""
with self.assertRaises(ValueError):
TimeoutConfig.from_total(0)
with self.assertRaises(ValueError):
TimeoutConfig.from_total(-1)

def test_as_tuple(self):
"""Test getting timeout as tuple."""
config = TimeoutConfig(connect=2.0, read=20.0)
self.assertEqual(config.as_tuple, (2.0, 20.0))

def test_default_factory(self):
"""Test default timeout factory method."""
config = TimeoutConfig.default()
self.assertEqual(config.connect, 3.05)
self.assertEqual(config.read, 27.0)

def test_string_representation(self):
"""Test string representation of TimeoutConfig."""
config = TimeoutConfig(connect=2.0, read=20.0)
self.assertEqual(str(config), "TimeoutConfig(connect=2.00s, read=20.00s)")

0 comments on commit 19991b0

Please sign in to comment.