Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions doc/source/advanced/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,15 @@ Set proxy once in config, affects all yfinance data fetches.

import yfinance as yf
yf.set_config(proxy="PROXY_SERVER")

Retries
-------

Configure automatic retry for transient network errors. The retry mechanism uses exponential backoff (1s, 2s, 4s...).

.. code-block:: python

import yfinance as yf
yf.set_config(retries=2)

Set to 0 to disable retries (default behavior).
16 changes: 16 additions & 0 deletions tests/test_prices.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from tests.context import session_gbl

import unittest
import socket

import datetime as _dt
import pytz as _tz
Expand Down Expand Up @@ -456,6 +457,21 @@ def test_aggregate_capital_gains(self):

dat.history(start=start, end=end, interval=interval)

def test_transient_error_detection(self):
"""Test that _is_transient_error correctly identifies transient vs permanent errors"""
from yfinance.data import _is_transient_error
from yfinance.exceptions import YFPricesMissingError

# Transient errors (should retry)
self.assertTrue(_is_transient_error(socket.error("Network error")))
self.assertTrue(_is_transient_error(TimeoutError("Timeout")))
self.assertTrue(_is_transient_error(OSError("OS error")))

# Permanent errors (should NOT retry)
self.assertFalse(_is_transient_error(ValueError("Invalid")))
self.assertFalse(_is_transient_error(YFPricesMissingError('INVALID', '')))
self.assertFalse(_is_transient_error(KeyError("key")))


if __name__ == '__main__':
unittest.main()
4 changes: 3 additions & 1 deletion yfinance/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@

# Config stuff:
_NOTSET=object()
def set_config(proxy=_NOTSET, hide_exceptions=_NOTSET):
def set_config(proxy=_NOTSET, retries=_NOTSET, hide_exceptions=_NOTSET):
if proxy is not _NOTSET:
YfData(proxy=proxy)
if retries is not _NOTSET:
YfConfig(retries=retries)
if hide_exceptions is not _NOTSET:
YfConfig(hide_exceptions=hide_exceptions)
__all__ += ["set_config"]
13 changes: 12 additions & 1 deletion yfinance/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,28 @@ def __call__(cls, *args, **kwargs):
if 'hide_exceptions' in kwargs or (args and len(args) > 0):
hide_exceptions = kwargs.get('hide_exceptions') if 'hide_exceptions' in kwargs else args[0]
cls._instances[cls]._set_hide_exceptions(hide_exceptions)
if 'retries' in kwargs or (args and len(args) > 1):
retries = kwargs.get('retries') if 'retries' in kwargs else args[1]
cls._instances[cls]._set_retries(retries)
return cls._instances[cls]


class YfConfig(metaclass=SingletonMeta):
def __init__(self, hide_exceptions=True):
def __init__(self, hide_exceptions=True, retries=0):
self._hide_exceptions = hide_exceptions
self._retries = retries

def _set_hide_exceptions(self, hide_exceptions):
self._hide_exceptions = hide_exceptions

def _set_retries(self, retries):
self._retries = retries

@property
def hide_exceptions(self):
return self._hide_exceptions

@property
def retries(self):
return self._retries

25 changes: 24 additions & 1 deletion yfinance/data.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import functools
from functools import lru_cache
import socket
import time as _time

from curl_cffi import requests
from urllib.parse import urlsplit, urljoin
Expand All @@ -9,10 +11,23 @@
from frozendict import frozendict

from . import utils, cache
from .config import YfConfig
import threading

from .exceptions import YFException, YFDataException, YFRateLimitError


def _is_transient_error(exception):
"""Check if error is transient (network/timeout) and should be retried."""
if isinstance(exception, (TimeoutError, socket.error, OSError)):
return True
error_type_name = type(exception).__name__
transient_error_types = {
'Timeout', 'TimeoutError', 'ConnectionError', 'ConnectTimeout',
'ReadTimeout', 'ChunkedEncodingError', 'RemoteDisconnected',
}
return error_type_name in transient_error_types

cache_maxsize = 64


Expand Down Expand Up @@ -416,7 +431,15 @@ def _make_request(self, url, request_method, body=None, params=None, timeout=30)
if body:
request_args['json'] = body

response = request_method(**request_args)
for attempt in range(YfConfig().retries + 1):
try:
response = request_method(**request_args)
break
except Exception as e:
if _is_transient_error(e) and attempt < YfConfig().retries:
_time.sleep(2 ** attempt)
else:
raise
utils.get_yf_logger().debug(f'response code={response.status_code}')
if response.status_code >= 400:
# Retry with other cookie strategy
Expand Down
4 changes: 1 addition & 3 deletions yfinance/multi.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,12 +286,10 @@ def _download_one(ticker, start=None, end=None,
rounding=rounding, keepna=keepna, timeout=timeout,
raise_errors=True
)
shared._DFS[ticker.upper()] = data
except Exception as e:
# glob try/except needed as current thead implementation breaks if exception is raised.
shared._DFS[ticker.upper()] = utils.empty_df()
shared._ERRORS[ticker.upper()] = repr(e)
shared._TRACEBACKS[ticker.upper()] = traceback.format_exc()
else:
shared._DFS[ticker.upper()] = data

return data
Loading