diff --git a/murgtools/getdata/getDataFRF.py b/murgtools/getdata/getDataFRF.py index b24b004..7cd15d3 100755 --- a/murgtools/getdata/getDataFRF.py +++ b/murgtools/getdata/getDataFRF.py @@ -25,6 +25,104 @@ from murgtools import config from murgtools.exceptions import InvalidGaugeError +# ============================================================================= +# Helper Functions for Network Fallback Patterns +# ============================================================================= + + +def open_dataset_with_fallback(primary_url, fallback_url, raise_on_failure=False): + """Open a NetCDF dataset with automatic fallback to secondary URL. + + Attempts to open a NetCDF dataset from the primary URL. If that fails + with an IOError or OSError (network issue, file not found), automatically + tries the fallback URL. + + Args: + primary_url (str): Primary URL to attempt first (e.g., FRF local server). + fallback_url (str): Fallback URL if primary fails (e.g., CHL public server). + raise_on_failure (bool): If True, raises IOError when both URLs fail. + If False (default), returns None on failure. + + Returns: + netCDF4.Dataset or None: The opened dataset, or None if both URLs fail + and raise_on_failure is False. + + Raises: + IOError: If both URLs fail and raise_on_failure=True. + + Example: + >>> ncfile = open_dataset_with_fallback( + ... self.FRFdataloc + self.dataloc, + ... self.chlDataLoc + self.dataloc, + ... raise_on_failure=True + ... ) + """ + primary_error = None + try: + return nc.Dataset(primary_url) + except (IOError, OSError) as e: + primary_error = e # Save for diagnostic message + + try: + return nc.Dataset(fallback_url) + except (IOError, OSError) as fallback_error: + if raise_on_failure: + raise IOError( + f"Failed to open dataset from both URLs.\n" + f" Primary ({primary_url}): {primary_error}\n" + f" Fallback ({fallback_url}): {fallback_error}" + ) from fallback_error + return None + + +def open_dataset_with_retry(url, max_attempts=None, retry_delay=5): + """Open a NetCDF dataset with retry logic for transient failures. + + Attempts to open a NetCDF dataset up to max_attempts times, with a delay + between failed attempts. Logs progress using the logging module. + + Args: + url (str): URL of the NetCDF file to open. + max_attempts (int, optional): Total number of attempts to make (not retries). + For example, max_attempts=3 means try up to 3 times total. + Defaults to config.MAX_RETRY_ATTEMPTS. Must be >= 1. + retry_delay (int or float): Seconds to wait between attempts. Default 5. + + Returns: + netCDF4.Dataset or None: The opened dataset, or None if all attempts fail. + + Raises: + ValueError: If max_attempts is less than 1. + + Example: + >>> ncfile = open_dataset_with_retry( + ... "http://server/data.nc", + ... max_attempts=3, # Try up to 3 times + ... retry_delay=10 + ... ) + """ + if max_attempts is None: + max_attempts = config.MAX_RETRY_ATTEMPTS + + if max_attempts < 1: + raise ValueError(f"max_attempts must be >= 1, got {max_attempts}") + + last_error = None + for attempt in range(max_attempts): + try: + return nc.Dataset(url) + except (IOError, OSError) as e: + last_error = e + if attempt < max_attempts - 1: + logging.warning( + f"Error reading {url}, attempt {attempt + 1}/{max_attempts}: {e}. Retrying..." + ) + time.sleep(retry_delay) + + logging.error(f"Failed to open {url} after {max_attempts} attempts: {last_error}") + return None + + def gettime(allEpoch, epochStart, epochEnd, indexRef=0): """This function opens the netcdf file, and retrieves time. @@ -1518,10 +1616,11 @@ def getWaveGaugeLoc(self, gaugenumber): see help on self.waveGaugeURLlookup for gauge keys """ self._waveGaugeURLlookup(gaugenumber) - try: - ncfile = nc.Dataset(self.FRFdataloc + self.dataloc) - except IOError: - ncfile = nc.Dataset(self.chlDataLoc + self.dataloc) + ncfile = open_dataset_with_fallback( + self.FRFdataloc + self.dataloc, + self.chlDataLoc + self.dataloc, + raise_on_failure=True + ) out = {'Lat': ncfile['latitude'][:], 'Lon': ncfile['longitude'][:]} return out @@ -2946,18 +3045,10 @@ def getModelField(self, var, prefix, local=True, ijLoc=None, model='STWAVE', **k model, prefix, grid, grid) elif model == 'CMS': # this is standard operational model url Structure fname = self.crunchDataLoc + u'waveModels/%s/%s/Field/Field.ncml' % (model, prefix) - finished = False - n = 0 - while not finished and n < 15: - try: - ncfile = nc.Dataset(fname) - finished = True - except IOError: - print('Error reading {}, trying again'.format(fname)) - time.sleep(10) - n += 1 - if not finished: - raise (RuntimeError, 'Data not accessible right now') + + ncfile = open_dataset_with_retry(fname, max_attempts=15, retry_delay=10) + if ncfile is None: + raise RuntimeError('Data not accessible right now: {}'.format(fname)) assert var in ncfile.variables.keys(), 'variable called is not in file please use\n%s' % \ ncfile.variables.keys() diff --git a/tests/test_getDataFRF.py b/tests/test_getDataFRF.py index 5a9ce56..ce96862 100644 --- a/tests/test_getDataFRF.py +++ b/tests/test_getDataFRF.py @@ -9,10 +9,157 @@ from unittest.mock import MagicMock, patch, PropertyMock from pyproj import Transformer -from murgtools.getdata.getDataFRF import get_geotiff_extent, gettime, removeDuplicatesFromDictionary +from murgtools.getdata.getDataFRF import ( + get_geotiff_extent, gettime, removeDuplicatesFromDictionary, + open_dataset_with_fallback, open_dataset_with_retry +) from murgtools.exceptions import InvalidGaugeError +class TestOpenDatasetWithFallback: + """Tests for the open_dataset_with_fallback helper function.""" + + def test_returns_dataset_from_primary_url(self): + """Test that primary URL is tried first and returned on success.""" + with patch('murgtools.getdata.getDataFRF.nc.Dataset') as mock_dataset: + mock_nc = MagicMock() + mock_dataset.return_value = mock_nc + + result = open_dataset_with_fallback('http://primary/data.nc', 'http://fallback/data.nc') + + assert result == mock_nc + mock_dataset.assert_called_once_with('http://primary/data.nc') + + def test_falls_back_to_secondary_url_on_ioerror(self): + """Test that fallback URL is used when primary fails.""" + with patch('murgtools.getdata.getDataFRF.nc.Dataset') as mock_dataset: + mock_nc = MagicMock() + mock_dataset.side_effect = [IOError("Primary failed"), mock_nc] + + result = open_dataset_with_fallback('http://primary/data.nc', 'http://fallback/data.nc') + + assert result == mock_nc + assert mock_dataset.call_count == 2 + mock_dataset.assert_any_call('http://primary/data.nc') + mock_dataset.assert_any_call('http://fallback/data.nc') + + def test_returns_none_when_both_fail_default(self): + """Test that None is returned when both URLs fail (default behavior).""" + with patch('murgtools.getdata.getDataFRF.nc.Dataset') as mock_dataset: + mock_dataset.side_effect = IOError("Failed") + + result = open_dataset_with_fallback('http://primary/data.nc', 'http://fallback/data.nc') + + assert result is None + + def test_raises_when_both_fail_and_raise_on_failure_true(self): + """Test that IOError is raised when both URLs fail and raise_on_failure=True.""" + with patch('murgtools.getdata.getDataFRF.nc.Dataset') as mock_dataset: + mock_dataset.side_effect = IOError("Failed") + + with pytest.raises(IOError) as exc_info: + open_dataset_with_fallback( + 'http://primary/data.nc', + 'http://fallback/data.nc', + raise_on_failure=True + ) + + assert "primary" in str(exc_info.value) + assert "fallback" in str(exc_info.value) + + def test_handles_oserror_as_well_as_ioerror(self): + """Test that OSError is also caught (Python 3 compatibility).""" + with patch('murgtools.getdata.getDataFRF.nc.Dataset') as mock_dataset: + mock_nc = MagicMock() + mock_dataset.side_effect = [OSError("Primary failed"), mock_nc] + + result = open_dataset_with_fallback('http://primary/data.nc', 'http://fallback/data.nc') + + assert result == mock_nc + + +class TestOpenDatasetWithRetry: + """Tests for the open_dataset_with_retry helper function.""" + + def test_returns_dataset_on_first_try(self): + """Test that dataset is returned immediately on success.""" + with patch('murgtools.getdata.getDataFRF.nc.Dataset') as mock_dataset: + mock_nc = MagicMock() + mock_dataset.return_value = mock_nc + + result = open_dataset_with_retry('http://server/data.nc', max_attempts=3) + + assert result == mock_nc + mock_dataset.assert_called_once() + + def test_retries_on_ioerror(self): + """Test that function retries on IOError.""" + with patch('murgtools.getdata.getDataFRF.nc.Dataset') as mock_dataset: + with patch('murgtools.getdata.getDataFRF.time.sleep'): + mock_nc = MagicMock() + mock_dataset.side_effect = [IOError("Fail 1"), IOError("Fail 2"), mock_nc] + + result = open_dataset_with_retry('http://server/data.nc', max_attempts=3, retry_delay=0) + + assert result == mock_nc + assert mock_dataset.call_count == 3 + + def test_returns_none_after_max_attempts(self): + """Test that None is returned after all attempts exhausted.""" + with patch('murgtools.getdata.getDataFRF.nc.Dataset') as mock_dataset: + with patch('murgtools.getdata.getDataFRF.time.sleep'): + mock_dataset.side_effect = IOError("Always fails") + + result = open_dataset_with_retry('http://server/data.nc', max_attempts=3, retry_delay=0) + + assert result is None + assert mock_dataset.call_count == 3 + + def test_uses_config_default_for_max_attempts(self): + """Test that config.MAX_RETRY_ATTEMPTS is used by default.""" + from murgtools import config + with patch('murgtools.getdata.getDataFRF.nc.Dataset') as mock_dataset: + with patch('murgtools.getdata.getDataFRF.time.sleep'): + mock_dataset.side_effect = IOError("Always fails") + + open_dataset_with_retry('http://server/data.nc', retry_delay=0) + + assert mock_dataset.call_count == config.MAX_RETRY_ATTEMPTS + + def test_logs_warning_on_retry(self, caplog): + """Test that warning is logged on each retry attempt.""" + import logging + with patch('murgtools.getdata.getDataFRF.nc.Dataset') as mock_dataset: + with patch('murgtools.getdata.getDataFRF.time.sleep'): + mock_nc = MagicMock() + mock_dataset.side_effect = [IOError("Fail"), mock_nc] + + with caplog.at_level(logging.WARNING): + open_dataset_with_retry('http://server/data.nc', max_attempts=2, retry_delay=0) + + assert "Error reading" in caplog.text + assert "http://server/data.nc" in caplog.text + + def test_logs_error_on_final_failure(self, caplog): + """Test that error is logged when all attempts fail.""" + import logging + with patch('murgtools.getdata.getDataFRF.nc.Dataset') as mock_dataset: + with patch('murgtools.getdata.getDataFRF.time.sleep'): + mock_dataset.side_effect = IOError("Always fails") + + with caplog.at_level(logging.ERROR): + result = open_dataset_with_retry('http://server/data.nc', max_attempts=2, retry_delay=0) + + assert result is None + assert "Failed to open" in caplog.text + + def test_raises_valueerror_for_invalid_max_attempts(self): + """Test that ValueError is raised when max_attempts < 1.""" + import pytest + with pytest.raises(ValueError, match="max_attempts must be >= 1"): + open_dataset_with_retry('http://server/data.nc', max_attempts=0) + + class TestGettime: """Tests for the gettime function."""