diff --git a/cwms/timeseries/timeseries.py b/cwms/timeseries/timeseries.py index 17a32947..9ce3dfec 100644 --- a/cwms/timeseries/timeseries.py +++ b/cwms/timeseries/timeseries.py @@ -166,6 +166,9 @@ def _call_with_retry(fn: Any, *args: Any, attempts: int = _CHUNK_ATTEMPTS) -> An try: return fn(*args) except Exception as e: + status_code = getattr(getattr(e, "response", None), "status_code", None) + if status_code == 404: + raise if i == attempts - 1: raise logging.warning(f"chunk attempt {i + 1}/{attempts} failed: {e}") diff --git a/tests/cda/timeseries/timeseries_CDA_test.py b/tests/cda/timeseries/timeseries_CDA_test.py index 83abc050..4e8a8c92 100644 --- a/tests/cda/timeseries/timeseries_CDA_test.py +++ b/tests/cda/timeseries/timeseries_CDA_test.py @@ -22,6 +22,7 @@ TEST_TSID_COPY_NULLS = f"{TEST_LOCATION_ID}.Stage.Inst.15Minutes.0.Raw-Copy-Nulls" TS_ID_REV_TEST = TEST_TSID_MULTI.replace("Raw-Multi", "Raw-Rev-Test") TEST_TSID_CHUNK_PARTIAL = f"{TEST_LOCATION_ID}.Stage.Inst.15Minutes.0.Raw-Multi-Partial" +TEST_TSID_MISSING = f"{TEST_LOCATION_ID}.Stage.Inst.15Minutes.0.Raw-Missing-404" # Generate 15-minute interval timestamps START_DATE_CHUNK_MULTI = datetime(2025, 7, 31, 0, 0, tzinfo=timezone.utc) END_DATE_CHUNK_MULTI = datetime(2025, 9, 30, 23, 45, tzinfo=timezone.utc) @@ -37,6 +38,7 @@ TEST_TSID_CHUNK_NULLS, TEST_TSID_COPY_NULLS, TEST_TSID_CHUNK_PARTIAL, + TEST_TSID_MISSING, TEST_TSID_DELETE, ] @@ -390,6 +392,33 @@ def sabotaged(selector, endpoint, param, begin, end): assert "simulated CDA failure" in error_msg +def test_get_timeseries_missing_chunk_404_real_api(): + """A missing time series should fail once with a 404, not retry.""" + + max_days = 14 + chunks = ts.chunk_timeseries_time_range( + START_DATE_CHUNK_MULTI, + END_DATE_CHUNK_MULTI.replace(tzinfo=timezone.utc), + timedelta(days=max_days), + ) + assert len(chunks) > 1, "Test requires multiple chunks to exercise retry scope" + + with pytest.raises(RuntimeError) as exc_info: + ts.get_timeseries( + ts_id=TEST_TSID_MISSING, + office_id=TEST_OFFICE, + begin=START_DATE_CHUNK_MULTI, + end=END_DATE_CHUNK_MULTI, + max_days_per_chunk=max_days, + unit="SI", + ) + + error_msg = str(exc_info.value) + assert "chunk(s) failed to fetch" in error_msg + assert "Failed to fetch data from" in error_msg + assert "Not Found" in error_msg or "404" in error_msg + + def test_store_timesereis_chunk_to_with_null_values(): # Define parameters ts_id = TEST_TSID_CHUNK_NULLS diff --git a/tests/mock/timeseries/timeseries_test.py b/tests/mock/timeseries/timeseries_test.py index 03e64c91..e5c906b7 100644 --- a/tests/mock/timeseries/timeseries_test.py +++ b/tests/mock/timeseries/timeseries_test.py @@ -251,6 +251,27 @@ def test_get_timeseries_paging(requests_mock): assert data.df.shape == (30, 3) +def test_call_with_retry_does_not_retry_404(): + class ResponseStub: + url = "https://mockwebserver.cwms.gov/timeseries" + status_code = 404 + reason = "Not Found" + content = b"" + + call_count = 0 + + def failing_call(): + nonlocal call_count + call_count += 1 + raise cwms.api.ApiError(ResponseStub()) + + with pytest.raises(cwms.api.ApiError) as exc_info: + timeseries._call_with_retry(failing_call) + + assert exc_info.value.response.status_code == 404 + assert call_count == 1 + + def test_get_timeseries_group_default(requests_mock): group_id = "USGS TS Data Acquisition" category_id = "Data Acquisition"