Skip to content

Commit 7593ed3

Browse files
committed
issues/181: Added retry logic for Harmony jobs that fail with 5xx errors
1 parent fc88de1 commit 7593ed3

3 files changed

Lines changed: 112 additions & 4 deletions

File tree

bignbit/get_harmony_job_status.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
import os
44

5+
import requests
56
from cumulus_logger import CumulusLogger
67
from cumulus_process import Process
78
from harmony import LinkType
@@ -12,6 +13,13 @@
1213
CUMULUS_LOGGER = CumulusLogger('get_harmony_job_status')
1314

1415

16+
class HarmonyTransientError(Exception):
17+
"""Exception raised when the Harmony API returns a transient 5xx error"""
18+
19+
def __init__(self, message):
20+
super().__init__(message)
21+
22+
1523
class HarmonyJobIncompleteError(Exception):
1624
"""Exception raised when a harmony job is not complete"""
1725

@@ -98,12 +106,26 @@ def check_harmony_job(
98106
"""
99107

100108
harmony_client = utils.get_harmony_client(cmr_env)
101-
job_status = harmony_client.status(harmony_job_id)
109+
try:
110+
job_status = harmony_client.status(harmony_job_id)
111+
except requests.exceptions.HTTPError as exc:
112+
if exc.response is not None and exc.response.status_code >= 500:
113+
raise HarmonyTransientError(
114+
f'Harmony API returned a transient error checking status of job {harmony_job_id}: {exc}'
115+
) from exc
116+
raise
102117

103118
# For a successful job, return the status; for all other states, raise an exception.
104119
if job_status.get('status') == 'successful':
105120
# Check that the harmony job returned data to confirm that the job was successful
106-
result_urls = list(harmony_client.result_urls(harmony_job_id, link_type=LinkType.s3))
121+
try:
122+
result_urls = list(harmony_client.result_urls(harmony_job_id, link_type=LinkType.s3))
123+
except requests.exceptions.HTTPError as exc:
124+
if exc.response is not None and exc.response.status_code >= 500:
125+
raise HarmonyTransientError(
126+
f'Harmony API returned a transient error fetching result URLs for job {harmony_job_id}: {exc}'
127+
) from exc
128+
raise
107129
if not result_urls:
108130
error_msg = (
109131
f'Harmony job {harmony_job_id} completed successfully but returned no data for {variable} and {crs}'

terraform/state_machine_definition.tpl

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,14 @@
338338
"BackoffRate":${HarmonyJobStatusBackoffRate},
339339
"MaxDelaySeconds":${HarmonyJobStatusMaxDelaySeconds}
340340
},
341+
{
342+
"ErrorEquals":[
343+
"HarmonyTransientError"
344+
],
345+
"IntervalSeconds":2,
346+
"MaxAttempts":6,
347+
"BackoffRate":2
348+
},
341349
{
342350
"ErrorEquals":[
343351
"Lambda.ServiceException",

tests/test_get_harmony_job_status.py

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
"""Unit tests for get_harmony_job_status module"""
2+
from unittest.mock import MagicMock, patch
3+
24
import pytest
5+
import requests
36
from moto import mock_s3
47

58
import bignbit.utils
6-
from bignbit.get_harmony_job_status import check_harmony_job, HarmonyJobNoDataError
9+
from bignbit.get_harmony_job_status import (
10+
check_harmony_job,
11+
HarmonyJobNoDataError,
12+
HarmonyTransientError,
13+
)
714

815

916
@pytest.mark.vcr
@@ -21,4 +28,75 @@ def test_process_results_no_data():
2128

2229
assert 'no data' in str(exc_info.value).lower()
2330
assert 'test_variable' in str(exc_info.value)
24-
assert 'EPSG:4326' in str(exc_info.value)
31+
assert 'EPSG:4326' in str(exc_info.value)
32+
33+
34+
def _make_http_error(status_code):
35+
"""Build a requests.exceptions.HTTPError with a mocked response."""
36+
response = MagicMock()
37+
response.status_code = status_code
38+
error = requests.exceptions.HTTPError(response=response)
39+
return error
40+
41+
42+
@patch('bignbit.get_harmony_job_status.utils.get_harmony_client')
43+
def test_check_harmony_job_raises_transient_error_on_500(mock_get_client):
44+
"""Test that HarmonyTransientError is raised when Harmony status returns a 500."""
45+
bignbit.utils.ED_USER = 'test'
46+
bignbit.utils.ED_PASS = 'test'
47+
48+
mock_client = MagicMock()
49+
mock_client.status.side_effect = _make_http_error(500)
50+
mock_get_client.return_value = mock_client
51+
52+
with pytest.raises(HarmonyTransientError) as exc_info:
53+
check_harmony_job('test-job-id', 'uat', 'test_variable', 'EPSG:4326')
54+
55+
assert 'transient error' in str(exc_info.value).lower()
56+
assert 'test-job-id' in str(exc_info.value)
57+
58+
59+
@patch('bignbit.get_harmony_job_status.utils.get_harmony_client')
60+
def test_check_harmony_job_raises_transient_error_on_503(mock_get_client):
61+
"""Test that HarmonyTransientError is raised when Harmony status returns a 503."""
62+
bignbit.utils.ED_USER = 'test'
63+
bignbit.utils.ED_PASS = 'test'
64+
65+
mock_client = MagicMock()
66+
mock_client.status.side_effect = _make_http_error(503)
67+
mock_get_client.return_value = mock_client
68+
69+
with pytest.raises(HarmonyTransientError):
70+
check_harmony_job('test-job-id', 'uat', 'test_variable', 'EPSG:4326')
71+
72+
73+
@patch('bignbit.get_harmony_job_status.utils.get_harmony_client')
74+
def test_check_harmony_job_reraises_non_5xx_http_error(mock_get_client):
75+
"""Test that non-5xx HTTPErrors (e.g. 404) are re-raised without wrapping."""
76+
bignbit.utils.ED_USER = 'test'
77+
bignbit.utils.ED_PASS = 'test'
78+
79+
mock_client = MagicMock()
80+
mock_client.status.side_effect = _make_http_error(404)
81+
mock_get_client.return_value = mock_client
82+
83+
with pytest.raises(requests.exceptions.HTTPError):
84+
check_harmony_job('test-job-id', 'uat', 'test_variable', 'EPSG:4326')
85+
86+
87+
@patch('bignbit.get_harmony_job_status.utils.get_harmony_client')
88+
def test_check_harmony_job_result_urls_raises_transient_error_on_500(mock_get_client):
89+
"""Test that HarmonyTransientError is raised when result_urls call returns a 500."""
90+
bignbit.utils.ED_USER = 'test'
91+
bignbit.utils.ED_PASS = 'test'
92+
93+
mock_client = MagicMock()
94+
mock_client.status.return_value = {'status': 'successful'}
95+
mock_client.result_urls.side_effect = _make_http_error(500)
96+
mock_get_client.return_value = mock_client
97+
98+
with pytest.raises(HarmonyTransientError) as exc_info:
99+
check_harmony_job('test-job-id', 'uat', 'test_variable', 'EPSG:4326')
100+
101+
assert 'result urls' in str(exc_info.value).lower()
102+
assert 'test-job-id' in str(exc_info.value)

0 commit comments

Comments
 (0)