Skip to content

Commit 230cf3a

Browse files
authored
DAS-2493: Implement retry behavior for server errors. (#69)
1 parent d767e3d commit 230cf3a

File tree

6 files changed

+93
-16
lines changed

6 files changed

+93
-16
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## [v1.2.2] - 2026-03-23
2+
3+
### Changed
4+
5+
- Failures encountered during downloading of OPeNDAP files are now retried
6+
if the error is not due to Authorization.
7+
18
## [v1.2.2] - 2026-01-22
29

310
### Changed
@@ -237,6 +244,7 @@ Repository structure changes include:
237244

238245
For more information on internal releases prior to NASA open-source approval,
239246
see legacy-CHANGELOG.md.
247+
[v1.2.3]: https://github.com/nasa/harmony-opendap-subsetter/releases/tag/1.2.3
240248
[v1.2.2]: https://github.com/nasa/harmony-opendap-subsetter/releases/tag/1.2.2
241249
[v1.2.1]: https://github.com/nasa/harmony-opendap-subsetter/releases/tag/1.2.1
242250
[v1.2.0]: https://github.com/nasa/harmony-opendap-subsetter/releases/tag/1.2.0

docker/service_version.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.2.2
1+
1.2.3

hoss/exceptions.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -293,16 +293,26 @@ def __init__(self, units_string):
293293
)
294294

295295

296-
class UrlAccessFailed(CustomNoRetryError):
297-
"""This exception is raised when an HTTP request for a given URL has a non
298-
500 error, and is therefore not retried.
296+
class UrlAccessFailed(CustomError):
297+
"""This exception is raised when an HTTP request for a given URL fails due
298+
to a server or transient network error that may succeed on retry.
299299
300300
"""
301301

302302
def __init__(self, url, status_code):
303303
super().__init__('UrlAccessFailed', f'{status_code} error retrieving: {url}')
304304

305305

306+
class UrlAccessForbidden(CustomNoRetryError):
307+
"""This exception is raised when access to a URL is forbidden (e.g., HTTP
308+
403) and should not be retried.
309+
310+
"""
311+
312+
def __init__(self, url, status_code):
313+
super().__init__('UrlAccessForbidden', f'{status_code} error retrieving: {url}')
314+
315+
306316
class InvalidVariableRequest(CustomNoRetryError):
307317
"""This exception is raised when invalid variables are requested,
308318
e.g., excluded science variables listed in the varinfo configuration.

hoss/utilities.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from harmony_service_lib.util import Config
2222
from harmony_service_lib.util import download as util_download
2323

24-
from hoss.exceptions import CustomNoRetryError, UrlAccessFailed
24+
from hoss.exceptions import CustomNoRetryError, UrlAccessFailed, UrlAccessForbidden
2525
from hoss.harmony_log_context import get_logger
2626

2727

@@ -166,7 +166,7 @@ def download_url(
166166
cfg=config,
167167
)
168168
except ForbiddenException as harmony_exception:
169-
raise UrlAccessFailed(url, 400) from harmony_exception
169+
raise UrlAccessForbidden(url, 403) from harmony_exception
170170
except ServerException as harmony_exception:
171171
raise UrlAccessFailed(url, 500) from harmony_exception
172172
except Exception as harmony_exception:

tests/test_adapter.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from unittest.mock import ANY, Mock, call, patch
1414

1515
from harmony_service_lib.exceptions import NoDataException, NoRetryException
16+
from harmony_service_lib.http import ForbiddenException
1617
from harmony_service_lib.message import Message
1718
from harmony_service_lib.util import HarmonyException, config
1819
from netCDF4 import Dataset
@@ -21,7 +22,7 @@
2122
from varinfo import VarInfoFromDmr
2223

2324
from hoss.adapter import HossAdapter
24-
from hoss.exceptions import InvalidVariableRequest
25+
from hoss.exceptions import InvalidVariableRequest, UrlAccessForbidden
2526
from tests.utilities import Granule, create_stac, write_dmr
2627

2728

@@ -2742,18 +2743,18 @@ def test_retriable_exception_handling(
27422743

27432744
@patch('hoss.adapter.mkdtemp')
27442745
@patch('shutil.rmtree')
2745-
@patch('hoss.subset.check_invalid_variable_request')
2746+
@patch('hoss.utilities.util_download')
27462747
@patch('hoss.adapter.stage')
27472748
def test_not_retriable_exception_handling(
2748-
self, mock_stage, mock_check_variable, mock_rmtree, mock_mkdtemp
2749+
self, mock_stage, mock_util_download, mock_rmtree, mock_mkdtemp
27492750
):
27502751
"""Ensure that if a not retriable exception is raised during
27512752
processing, this causes a NoRetryException to be raised, to prevent
27522753
extra Harmony CPU cycles.
27532754
27542755
"""
27552756
mock_mkdtemp.return_value = self.tmp_dir
2756-
mock_check_variable.side_effect = InvalidVariableRequest('Random error')
2757+
mock_util_download.side_effect = ForbiddenException('403 no permission error')
27572758

27582759
message = Message(
27592760
{

tests/unit/test_utilities.py

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,20 @@
22
from unittest import TestCase
33
from unittest.mock import Mock, patch
44

5-
from harmony_service_lib.exceptions import ForbiddenException, ServerException
5+
from harmony_service_lib.exceptions import (
6+
ForbiddenException,
7+
HarmonyException,
8+
NoRetryException,
9+
ServerException,
10+
)
611
from harmony_service_lib.util import config
712

8-
from hoss.exceptions import UrlAccessFailed
13+
from hoss.exceptions import (
14+
CustomError,
15+
CustomNoRetryError,
16+
UrlAccessFailed,
17+
UrlAccessForbidden,
18+
)
919
from hoss.harmony_log_context import set_logger
1020
from hoss.utilities import (
1121
download_url,
@@ -16,6 +26,7 @@
1626
get_opendap_nc4,
1727
get_value_or_default,
1828
move_downloaded_nc4,
29+
raise_from_hoss_exception,
1930
unexecuted_url_requested,
2031
)
2132

@@ -99,12 +110,15 @@ def test_download_url(self, mock_util_download):
99110
)
100111
mock_util_download.reset_mock()
101112

102-
with self.subTest('500 error is caught and handled.'):
113+
with self.subTest('500 error is caught and handled, and is retryable.'):
103114
mock_util_download.side_effect = [self.harmony_500_error, http_response]
104115

105-
with self.assertRaises(UrlAccessFailed):
116+
with self.assertRaises(UrlAccessFailed) as context:
106117
download_url(test_url, output_directory, access_token, self.config)
107118

119+
self.assertIsInstance(context.exception, CustomError)
120+
self.assertNotIsInstance(context.exception, CustomNoRetryError)
121+
108122
mock_util_download.assert_called_once_with(
109123
test_url,
110124
output_directory,
@@ -115,12 +129,36 @@ def test_download_url(self, mock_util_download):
115129
)
116130
mock_util_download.reset_mock()
117131

118-
with self.subTest('Non-500 error does not retry, and is re-raised.'):
132+
with self.subTest('A 403 error (forbidden) is not retried.'):
119133
mock_util_download.side_effect = [self.harmony_auth_error, http_response]
120134

121-
with self.assertRaises(UrlAccessFailed):
135+
with self.assertRaises(UrlAccessForbidden) as context:
136+
download_url(test_url, output_directory, access_token, self.config)
137+
138+
self.assertIsInstance(context.exception, CustomNoRetryError)
139+
140+
mock_util_download.assert_called_once_with(
141+
test_url,
142+
output_directory,
143+
self.logger,
144+
access_token=access_token,
145+
data=None,
146+
cfg=self.config,
147+
)
148+
mock_util_download.reset_mock()
149+
150+
with self.subTest('Unknown/transient error is caught and is retryable.'):
151+
mock_util_download.side_effect = [
152+
Exception('something broke'),
153+
http_response,
154+
]
155+
156+
with self.assertRaises(UrlAccessFailed) as context:
122157
download_url(test_url, output_directory, access_token, self.config)
123158

159+
self.assertIsInstance(context.exception, CustomError)
160+
self.assertNotIsInstance(context.exception, CustomNoRetryError)
161+
124162
mock_util_download.assert_called_once_with(
125163
test_url,
126164
output_directory,
@@ -335,3 +373,23 @@ def test_unexecuted_url_requested(self):
335373

336374
with self.subTest('Format type is None'):
337375
self.assertFalse(unexecuted_url_requested(None))
376+
377+
def test_raise_from_hoss_exception(self):
378+
"""Ensure that HOSS exceptions are correctly converted to Harmony
379+
exceptions. CustomNoRetryError subclasses should become
380+
NoRetryException, while CustomError subclasses should become a
381+
retryable HarmonyException.
382+
383+
"""
384+
test_url = 'fake_website.com'
385+
386+
with self.subTest('UrlAccessForbidden (no-retry) raises NoRetryException.'):
387+
forbidden_exception = UrlAccessForbidden(test_url, 403)
388+
with self.assertRaises(NoRetryException):
389+
raise_from_hoss_exception(forbidden_exception)
390+
391+
with self.subTest('UrlAccessFailed (retryable) raises HarmonyException.'):
392+
failed_exception = UrlAccessFailed(test_url, 500)
393+
with self.assertRaises(HarmonyException) as context:
394+
raise_from_hoss_exception(failed_exception)
395+
self.assertNotIsInstance(context.exception, NoRetryException)

0 commit comments

Comments
 (0)