Skip to content
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import re
from collections.abc import Callable, Mapping, Sequence
from datetime import datetime, timedelta, timezone
Expand Down Expand Up @@ -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__(
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down
81 changes: 81 additions & 0 deletions providers/openfeature-provider-ofrep/tests/test_provider.py
Original file line number Diff line number Diff line change
@@ -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,
)
Expand Down Expand Up @@ -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",
Expand All @@ -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"
Expand Down
Loading