diff --git a/providers/openfeature-provider-ofrep/src/openfeature/contrib/provider/ofrep/__init__.py b/providers/openfeature-provider-ofrep/src/openfeature/contrib/provider/ofrep/__init__.py index cf9e4cc4..f9f268f9 100644 --- a/providers/openfeature-provider-ofrep/src/openfeature/contrib/provider/ofrep/__init__.py +++ b/providers/openfeature-provider-ofrep/src/openfeature/contrib/provider/ofrep/__init__.py @@ -1,3 +1,4 @@ +import logging import re from collections.abc import Callable, Mapping, Sequence from datetime import datetime, timedelta, timezone @@ -30,12 +31,16 @@ __all__ = ["OFREPProvider"] +logger = logging.getLogger("openfeature.contrib.ofrep") + TypeMap = dict[ FlagType, type[bool] | type[int] | type[float] | type[str] | tuple[type[dict], type[list]], ] +_HTTP_AUTH_ERRORS: dict[int, str] = {401: "unauthorized", 403: "forbidden"} + class OFREPProvider(AbstractProvider): def __init__( @@ -161,24 +166,22 @@ def _handle_error(self, exception: requests.RequestException) -> NoReturn: if response is None: raise GeneralError(str(exception)) from exception - if response.status_code == 429: - retry_after = response.headers.get("Retry-After") - self.retry_after = _parse_retry_after(retry_after) - raise GeneralError( - f"Rate limited, retry after: {retry_after}" - ) from exception - + self._raise_for_http_status(response, exception) + # Fallthrough: parse JSON and raise based on error code try: data = response.json() except JSONDecodeError: raise ParseError(str(exception)) from exception - error_code = ErrorCode(data["errorCode"]) + try: + error_code = ErrorCode(data["errorCode"]) + except ValueError: + logger.warning( + "Invalid errorCode %r, falling back to GENERAL", data.get("errorCode") + ) + error_code = ErrorCode.GENERAL error_details = data["errorDetails"] - if response.status_code == 404: - raise FlagNotFoundError(error_details) from exception - if error_code == ErrorCode.PARSE_ERROR: raise ParseError(error_details) from exception if error_code == ErrorCode.TARGETING_KEY_MISSING: @@ -190,6 +193,32 @@ def _handle_error(self, exception: requests.RequestException) -> NoReturn: raise OpenFeatureError(error_code, error_details) from exception + def _raise_for_http_status( + self, + response: requests.Response, + exception: requests.RequestException, + ) -> None: + status = response.status_code + + if status == 429: + retry_after = response.headers.get("Retry-After") + self.retry_after = _parse_retry_after(retry_after) + raise GeneralError( + f"Rate limited, retry after: {retry_after}" + ) from exception + elif status in _HTTP_AUTH_ERRORS: + raise OpenFeatureError( + ErrorCode.GENERAL, _HTTP_AUTH_ERRORS[status] + ) from exception + elif status == 404: + try: + error_details = response.json()["errorDetails"] + except (JSONDecodeError, KeyError): + error_details = response.text + raise FlagNotFoundError(error_details) from exception + elif status > 400: + raise OpenFeatureError(ErrorCode.GENERAL, response.text) from exception + def _build_request_data( evaluation_context: EvaluationContext | None, diff --git a/providers/openfeature-provider-ofrep/tests/test_provider.py b/providers/openfeature-provider-ofrep/tests/test_provider.py index a84b369d..af340d4f 100644 --- a/providers/openfeature-provider-ofrep/tests/test_provider.py +++ b/providers/openfeature-provider-ofrep/tests/test_provider.py @@ -1,11 +1,15 @@ +import logging + import pytest from openfeature.contrib.provider.ofrep import OFREPProvider from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ( + ErrorCode, FlagNotFoundError, GeneralError, InvalidContextError, + OpenFeatureError, ParseError, TypeMismatchError, ) @@ -83,6 +87,61 @@ def test_provider_flag_not_found(ofrep_provider, requests_mock): ofrep_provider.resolve_boolean_details("flag_key", False) +def test_provider_unauthorized(ofrep_provider, requests_mock): + requests_mock.post( + "http://localhost:8080/ofrep/v1/evaluate/flags/flag_key", + status_code=401, + text="unauthorized", + ) + + with pytest.raises(OpenFeatureError) as exc_info: + ofrep_provider.resolve_boolean_details("flag_key", False) + + assert exc_info.value.error_code == ErrorCode.GENERAL + assert exc_info.value.error_message == "unauthorized" + + +def test_provider_forbidden(ofrep_provider, requests_mock): + requests_mock.post( + "http://localhost:8080/ofrep/v1/evaluate/flags/flag_key", + status_code=403, + text="forbidden", + ) + + with pytest.raises(OpenFeatureError) as exc_info: + ofrep_provider.resolve_boolean_details("flag_key", False) + + assert exc_info.value.error_code == ErrorCode.GENERAL + assert exc_info.value.error_message == "forbidden" + + +def test_provider_flag_not_found_invalid_json(ofrep_provider, requests_mock): + """Test 404 with non-JSON response falls back to response text for error details""" + requests_mock.post( + "http://localhost:8080/ofrep/v1/evaluate/flags/flag_key", + status_code=404, + text="Flag not found - plain text response", + ) + + with pytest.raises(FlagNotFoundError, match="Flag not found - plain text response"): + ofrep_provider.resolve_boolean_details("flag_key", False) + + +def test_provider_server_error(ofrep_provider, requests_mock): + """Test generic OpenFeatureError for status codes > 400 (e.g. 500, 502)""" + requests_mock.post( + "http://localhost:8080/ofrep/v1/evaluate/flags/flag_key", + status_code=500, + text="Internal Server Error", + ) + + with pytest.raises(OpenFeatureError) as exc_info: + ofrep_provider.resolve_boolean_details("flag_key", False) + + assert exc_info.value.error_code == ErrorCode.GENERAL + assert exc_info.value.error_message == "Internal Server Error" + + def test_provider_invalid_context(ofrep_provider, requests_mock): requests_mock.post( "http://localhost:8080/ofrep/v1/evaluate/flags/flag_key", @@ -98,6 +157,28 @@ def test_provider_invalid_context(ofrep_provider, requests_mock): ofrep_provider.resolve_boolean_details("flag_key", False) +def test_provider_invalid_error_code_logs_warning( + ofrep_provider, requests_mock, caplog +): + requests_mock.post( + "http://localhost:8080/ofrep/v1/evaluate/flags/flag_key", + status_code=400, + json={ + "key": "flag_key", + "errorCode": "UNKNOWN_CODE", + "errorDetails": "Something went wrong", + }, + ) + + with ( + caplog.at_level(logging.WARNING, logger="openfeature.contrib.ofrep"), + pytest.raises(GeneralError), + ): + ofrep_provider.resolve_boolean_details("flag_key", False) + + assert any("UNKNOWN_CODE" in record.message for record in caplog.records) + + def test_provider_invalid_response(ofrep_provider, requests_mock): requests_mock.post( "http://localhost:8080/ofrep/v1/evaluate/flags/flag_key", text="invalid"