diff --git a/src/k1link/simulation/gaussian_pipeline_gateway.py b/src/k1link/simulation/gaussian_pipeline_gateway.py index fe2b78c..5aa98f2 100644 --- a/src/k1link/simulation/gaussian_pipeline_gateway.py +++ b/src/k1link/simulation/gaussian_pipeline_gateway.py @@ -36,6 +36,7 @@ IMAGE_DIGEST_PATTERN: Final = re.compile(r"^sha256:[a-f0-9]{64}$") DEFAULT_CHUNK_BYTES: Final = 8 * 1024 * 1024 MAX_JSON_RESPONSE_BYTES: Final = 32 * 1024 * 1024 MAX_RETRIES: Final = 3 +RETRYABLE_PROVIDER_STATUS_CODES: Final = {502, 503, 504} DEFAULT_INGEST_TIMEOUT_SECONDS: Final = 30 * 60.0 @@ -899,7 +900,10 @@ def _validate_runtime_provenance(document: Mapping[str, object]) -> None: def _unavailable(message: str, error: httpx.HTTPError) -> GaussianPipelineGatewayError: - if isinstance(error, httpx.TransportError): + if isinstance(error, httpx.TransportError) or ( + isinstance(error, httpx.HTTPStatusError) + and error.response.status_code in RETRYABLE_PROVIDER_STATUS_CODES + ): return GaussianPipelineUnavailableError(message) return GaussianPipelineGatewayError(message) @@ -922,4 +926,6 @@ def _provider_rejection(response: httpx.Response) -> GaussianPipelineGatewayErro message = f"Gaussian provider rejected request (HTTP {response.status_code})" if detail is not None: message = f"{message}: {detail}" + if response.status_code in RETRYABLE_PROVIDER_STATUS_CODES: + return GaussianPipelineUnavailableError(message) return GaussianPipelineGatewayError(message) diff --git a/tests/test_gaussian_pipeline_gateway.py b/tests/test_gaussian_pipeline_gateway.py index 1537e95..f1850e7 100644 --- a/tests/test_gaussian_pipeline_gateway.py +++ b/tests/test_gaussian_pipeline_gateway.py @@ -14,6 +14,7 @@ from k1link.simulation.gaussian_pipeline_gateway import ( GaussianPipelineGateway, GaussianPipelineGatewayError, GaussianPipelineIntegrityError, + GaussianPipelineUnavailableError, _discover_bundle_members, ) @@ -256,6 +257,30 @@ def test_gateway_surfaces_bounded_provider_rejection_detail(tmp_path: Path) -> N )) +@pytest.mark.parametrize("status_code", [502, 503, 504]) +def test_gateway_classifies_temporary_provider_proxy_failures_as_unavailable( + tmp_path: Path, + status_code: int, +) -> None: + with ( + GaussianPipelineGateway( + "http://gaussian.test", + _token_file(tmp_path), + transport=httpx.MockTransport( + lambda _request: httpx.Response( + status_code, + json={"error": "provider_unavailable"}, + ) + ), + ) as gateway, + pytest.raises( + GaussianPipelineUnavailableError, + match=rf"HTTP {status_code}.*provider_unavailable", + ), + ): + gateway.capabilities() + + def test_gateway_rejects_incomplete_lcc_bundle(tmp_path: Path) -> None: bundle = tmp_path / "bundle" bundle.mkdir()