Skip to content

Commit 587aef6

Browse files
authored
Merge pull request #70 from nasa/DAS-2481-hoss-noretry
DAS-2481 Add no reties for ServiceException response bad request 400
2 parents 230cf3a + 7ef64f1 commit 587aef6

File tree

7 files changed

+231
-13
lines changed

7 files changed

+231
-13
lines changed

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
## [v1.2.2] - 2026-03-23
1+
## [v1.2.4] - 2026-04-09
2+
3+
### Changed
4+
5+
- Ensure Harmony ServerExceptions with HTTP 500 are caught and retried,
6+
while all other status codes are re-raised as NoRetryExceptions.
7+
8+
## [v1.2.3] - 2026-03-23
29

310
### Changed
411

docker/service_version.txt

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

hoss/adapter.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from pystac import Asset, Item
3636

3737
from hoss.dimension_utilities import is_index_subset
38+
from hoss.exceptions import StagingFailed
3839
from hoss.harmony_log_context import set_logger
3940
from hoss.subset import subset_granule
4041
from hoss.utilities import (
@@ -133,12 +134,10 @@ def process_item(self, item: Item, source: Source):
133134
),
134135
)
135136
mime, _ = get_file_mimetype(output_url)
136-
url = stage(
137+
url = self.hoss_stage(
137138
output_url,
138139
asset_name,
139140
mime,
140-
location=self.message.stagingLocation,
141-
logger=self.logger,
142141
)
143142

144143
# Update the STAC record
@@ -189,3 +188,22 @@ def validate_message(self):
189188
for source in self.message.sources:
190189
if not hasattr(source, 'variables') or not source.variables:
191190
self.logger.info('All variables will be retrieved.')
191+
192+
def hoss_stage(self, url: str, asset_name: str, output_mimetype: str) -> str:
193+
"""Stages the file to and S3 location and returns the url.
194+
Throws a retriable exception when there is a failure.
195+
196+
"""
197+
try:
198+
output_url = stage(
199+
url,
200+
asset_name,
201+
output_mimetype,
202+
location=self.message.stagingLocation,
203+
logger=self.logger,
204+
)
205+
206+
return output_url
207+
208+
except Exception as exception:
209+
raise StagingFailed('Staging failed, ' + str(exception)) from exception

hoss/exceptions.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,3 +325,26 @@ def __init__(self, variable_names):
325325
f'Some variables requested are not supported and could not be processed: '
326326
f'"{variable_names}".',
327327
)
328+
329+
330+
class UrlAccessFailedWithNoRetries(CustomNoRetryError):
331+
"""This exception is raised when an HTTP request for a given URL fails due
332+
to a server error other than response code 500.
333+
334+
"""
335+
336+
def __init__(self, url, status_code):
337+
super().__init__(
338+
'UrlAccessFailedWithNoRetries',
339+
f'{status_code} error retrieving: {url}',
340+
)
341+
342+
343+
class StagingFailed(CustomError):
344+
"""This Exception is used as a catch any errors
345+
during staging. This will be retriable exception.
346+
347+
"""
348+
349+
def __init__(self, message):
350+
super().__init__('StagingFailed', message)

hoss/utilities.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
import mimetypes
8+
import re
89
from os import sep
910
from os.path import splitext
1011
from shutil import move
@@ -21,7 +22,12 @@
2122
from harmony_service_lib.util import Config
2223
from harmony_service_lib.util import download as util_download
2324

24-
from hoss.exceptions import CustomNoRetryError, UrlAccessFailed, UrlAccessForbidden
25+
from hoss.exceptions import (
26+
CustomError,
27+
UrlAccessFailed,
28+
UrlAccessFailedWithNoRetries,
29+
UrlAccessForbidden,
30+
)
2531
from hoss.harmony_log_context import get_logger
2632

2733

@@ -168,7 +174,7 @@ def download_url(
168174
except ForbiddenException as harmony_exception:
169175
raise UrlAccessForbidden(url, 403) from harmony_exception
170176
except ServerException as harmony_exception:
171-
raise UrlAccessFailed(url, 500) from harmony_exception
177+
handle_harmony_server_exception(url, harmony_exception)
172178
except Exception as harmony_exception:
173179
raise UrlAccessFailed(url, 'Unknown') from harmony_exception
174180

@@ -204,16 +210,43 @@ def raise_from_hoss_exception(exception: Exception):
204210
"""Convert a HOSS exception to an appropriate Harmony exception.
205211
206212
Translates HOSS-specific exceptions into Harmony framework exceptions,
207-
preserving the exception chain. CustomNoRetryError exceptions are converted
208-
to NoRetryException, while all other exceptions become HarmonyException
209-
instances.
213+
preserving the exception chain. Ensures CustomError (download/staging)
214+
are retried via HarmonyException, and all other errors fail permanently
215+
with NoRetryException
210216
211217
"""
212-
if issubclass(type(exception), CustomNoRetryError):
213-
ExceptionClass = NoRetryException
214-
else:
218+
if issubclass(type(exception), CustomError):
215219
ExceptionClass = HarmonyException
220+
else:
221+
ExceptionClass = NoRetryException
216222

217223
raise ExceptionClass(
218224
'Subsetter failed with error: ' + str(exception)
219225
) from exception
226+
227+
228+
def handle_harmony_server_exception(url: str, harmony_exception: Exception):
229+
"""
230+
Parses a harmony ServerException for the status code and raises the
231+
appropriate UrlAccess exception based on whether it is a 500 or another code.
232+
233+
"""
234+
235+
message = str(harmony_exception)
236+
237+
# Search for "status code: <number>" in the exception message
238+
match = re.search(r'status code:\s*(\d+)', message)
239+
240+
if match:
241+
status_code = int(match.group(1))
242+
243+
# Retry downloads on HTTP 500 reponse code (transient)
244+
# Fail without retries for all other status codes
245+
if status_code == 500:
246+
raise UrlAccessFailed(url, 500) from harmony_exception
247+
248+
raise UrlAccessFailedWithNoRetries(url, status_code) from harmony_exception
249+
250+
# Fallback in case the exception message doesn't contain a status code
251+
# Defaulting to 500
252+
raise UrlAccessFailed(url, 500) from harmony_exception

tests/unit/test_adapter.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
from os.path import join as path_join
23
from typing import Dict, List, Optional
34
from unittest import TestCase
45
from unittest.mock import ANY, patch
@@ -8,6 +9,7 @@
89

910
from hoss.adapter import HossAdapter
1011
from hoss.bbox_utilities import BBox
12+
from hoss.exceptions import CustomError, StagingFailed
1113
from tests.utilities import Granule, create_stac, spy_on
1214

1315

@@ -33,6 +35,7 @@ def setUpClass(cls):
3335
cls.africa_stac = create_stac(
3436
[Granule(cls.africa_granule_url, None, ['opendap', 'data'])]
3537
)
38+
cls.staging_location = 's3://example-bucket'
3639

3740
def setUp(self):
3841
self.config = config(validate=False)
@@ -559,3 +562,72 @@ def test_missing_variables(
559562
location='s3://example-bucket/',
560563
logger=hoss.logger,
561564
)
565+
566+
def test_hoss_stage(self, mock_stage, mock_subset_granule, mock_get_mimetype):
567+
"""Ensure hoss_stage successfully calls hoss_stage and returns the URL"""
568+
mock_subset_granule.return_value = '/path/to/output.nc'
569+
expected_output_basename = '/path/to/output.nc'
570+
mock_get_mimetype.return_value = ('application/x-netcdf4', None)
571+
expected_staged_url = path_join(self.staging_location, expected_output_basename)
572+
mock_stage.return_value = expected_staged_url
573+
574+
collection_short_name = 'harmony_example_l2'
575+
576+
message = self.create_message(
577+
'C1233860183-EEDTEST', collection_short_name, [], 'jlovell'
578+
)
579+
580+
hoss = HossAdapter(message, config=self.config, catalog=self.africa_stac)
581+
582+
with patch.object(HossAdapter, 'process_item', self.process_item_spy):
583+
hoss.invoke()
584+
585+
mock_subset_granule.assert_called_once_with(
586+
self.africa_granule_url,
587+
message.sources[0],
588+
ANY,
589+
hoss.message,
590+
hoss.config,
591+
)
592+
593+
mock_get_mimetype.assert_called_once_with('/path/to/output.nc')
594+
595+
result = hoss.hoss_stage(
596+
expected_output_basename,
597+
'africa.nc4',
598+
'application/x-netcdf4',
599+
)
600+
601+
assert result == expected_staged_url
602+
603+
def test_hoss_stage_raises_exception(
604+
self, mock_stage, mock_subset_granule, mock_get_mimetype
605+
):
606+
"""Ensure hoss_stage raises an StagingFailed exception when
607+
the Harmony stage function throws an exception.
608+
609+
"""
610+
mock_stage.side_effect = Exception("Connection timeout")
611+
mock_subset_granule.return_value = '/path/to/output.nc'
612+
mock_get_mimetype.return_value = ('application/x-netcdf4', None)
613+
614+
collection_short_name = 'harmony_example_l2'
615+
616+
message = self.create_message(
617+
'C1233860183-EEDTEST', collection_short_name, [], 'jlovell'
618+
)
619+
620+
hoss = HossAdapter(message, config=self.config, catalog=self.africa_stac)
621+
622+
with self.assertRaises(StagingFailed) as context_manager:
623+
hoss.hoss_stage(
624+
'/path/to/output.nc',
625+
'africa.nc4',
626+
'application/x-netcdf4',
627+
)
628+
629+
self.assertIsInstance(context_manager.exception, CustomError)
630+
self.assertEqual(
631+
str(context_manager.exception),
632+
'Staging failed, Connection timeout',
633+
)

tests/unit/test_utilities.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
from hoss.exceptions import (
1414
CustomError,
1515
CustomNoRetryError,
16+
StagingFailed,
1617
UrlAccessFailed,
18+
UrlAccessFailedWithNoRetries,
1719
UrlAccessForbidden,
1820
)
1921
from hoss.harmony_log_context import set_logger
@@ -37,6 +39,12 @@ class TestUtilities(TestCase):
3739
@classmethod
3840
def setUpClass(cls):
3941
cls.harmony_500_error = ServerException('I can\'t do that')
42+
cls.harmony_500_status_code_error = ServerException(
43+
'Unable to download due to status code: 500 and content'
44+
)
45+
cls.harmony_400_status_code_error = ServerException(
46+
'Unable to download due to status code: 400 and content'
47+
)
4048
cls.harmony_auth_error = ForbiddenException('You can\'t do that.')
4149
cls.config = config(validate=False)
4250
cls.logger = getLogger('test')
@@ -129,6 +137,28 @@ def test_download_url(self, mock_util_download):
129137
)
130138
mock_util_download.reset_mock()
131139

140+
with self.subTest('Harmony server exception 500 error should be retried.'):
141+
mock_util_download.side_effect = [
142+
self.harmony_500_status_code_error,
143+
http_response,
144+
]
145+
146+
with self.assertRaises(UrlAccessFailed) as context:
147+
download_url(test_url, output_directory, access_token, self.config)
148+
149+
self.assertIsInstance(context.exception, CustomError)
150+
self.assertNotIsInstance(context.exception, CustomNoRetryError)
151+
152+
mock_util_download.assert_called_once_with(
153+
test_url,
154+
output_directory,
155+
self.logger,
156+
access_token=access_token,
157+
data=None,
158+
cfg=self.config,
159+
)
160+
mock_util_download.reset_mock()
161+
132162
with self.subTest('A 403 error (forbidden) is not retried.'):
133163
mock_util_download.side_effect = [self.harmony_auth_error, http_response]
134164

@@ -147,6 +177,28 @@ def test_download_url(self, mock_util_download):
147177
)
148178
mock_util_download.reset_mock()
149179

180+
with self.subTest('A 400 error (Bad Request) is not retried.'):
181+
mock_util_download.side_effect = [
182+
self.harmony_400_status_code_error,
183+
http_response,
184+
]
185+
186+
with self.assertRaises(UrlAccessFailedWithNoRetries) as context:
187+
download_url(test_url, output_directory, access_token, self.config)
188+
189+
self.assertIsInstance(context.exception, CustomNoRetryError)
190+
self.assertNotIsInstance(context.exception, CustomError)
191+
192+
mock_util_download.assert_called_once_with(
193+
test_url,
194+
output_directory,
195+
self.logger,
196+
access_token=access_token,
197+
data=None,
198+
cfg=self.config,
199+
)
200+
mock_util_download.reset_mock()
201+
150202
with self.subTest('Unknown/transient error is caught and is retryable.'):
151203
mock_util_download.side_effect = [
152204
Exception('something broke'),
@@ -393,3 +445,16 @@ def test_raise_from_hoss_exception(self):
393445
with self.assertRaises(HarmonyException) as context:
394446
raise_from_hoss_exception(failed_exception)
395447
self.assertNotIsInstance(context.exception, NoRetryException)
448+
449+
with self.subTest(
450+
'UrlAccessFailedWithNoRetries (no-retry) raises NoRetryException.'
451+
):
452+
failed_exception = UrlAccessFailedWithNoRetries(test_url, 400)
453+
with self.assertRaises(NoRetryException):
454+
raise_from_hoss_exception(failed_exception)
455+
456+
with self.subTest('StagingFailed (retryable) raises HarmonyException.'):
457+
failed_exception = StagingFailed('Staging failed, Connection timeout')
458+
with self.assertRaises(HarmonyException) as context:
459+
raise_from_hoss_exception(failed_exception)
460+
self.assertNotIsInstance(context.exception, NoRetryException)

0 commit comments

Comments
 (0)