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
123 changes: 107 additions & 16 deletions murgtools/getdata/getDataFRF.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
149 changes: 148 additions & 1 deletion tests/test_getDataFRF.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading