From 8c6a233dda4a898e496c1a5f044ba8c581ae1661 Mon Sep 17 00:00:00 2001 From: Jose Colella Date: Sat, 7 Mar 2026 08:13:34 -0800 Subject: [PATCH 1/6] feat: add OFREP provider Add a vendor-neutral OFREP (OpenFeature Remote Evaluation Protocol) provider gem for the OpenFeature Ruby SDK. This provider communicates with any OFREP-compliant feature flag server via the standard OFREP HTTP API. Includes: - Configuration (base_url, headers, timeout) - HTTP client with rate limiting (Retry-After) support - Response parsing with reason/error_code mapping - Type validation for boolean, string, number, integer, float, object - Comprehensive test suite with webmock - CI workflow, release-please config, and component owners Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jose Colella --- .github/component_owners.yml | 2 + .github/workflows/ruby.yml | 22 + .release-please-manifest.json | 1 + providers/openfeature-ofrep-provider/.rspec | 2 + .../openfeature-ofrep-provider/.rubocop.yml | 5 + .../openfeature-ofrep-provider/CHANGELOG.md | 1 + providers/openfeature-ofrep-provider/Gemfile | 3 + .../openfeature-ofrep-provider/Gemfile.lock | 162 ++++++ .../openfeature-ofrep-provider/README.md | 59 +++ providers/openfeature-ofrep-provider/Rakefile | 10 + providers/openfeature-ofrep-provider/bin/rake | 27 + .../lib/openfeature/ofrep/provider.rb | 107 ++++ .../lib/openfeature/ofrep/provider/client.rb | 169 +++++++ .../ofrep/provider/configuration.rb | 29 ++ .../lib/openfeature/ofrep/provider/errors.rb | 77 +++ .../openfeature/ofrep/provider/response.rb | 34 ++ .../lib/openfeature/ofrep/provider/version.rb | 7 + .../openfeature-ofrep-provider.gemspec | 41 ++ .../openfeature/ofrep/provider/client_spec.rb | 187 +++++++ .../ofrep/provider/configuration_spec.rb | 41 ++ .../spec/openfeature/ofrep/provider_spec.rb | 475 ++++++++++++++++++ .../spec/spec_helper.rb | 25 + release-please-config.json | 10 + 23 files changed, 1496 insertions(+) create mode 100644 providers/openfeature-ofrep-provider/.rspec create mode 100644 providers/openfeature-ofrep-provider/.rubocop.yml create mode 100644 providers/openfeature-ofrep-provider/CHANGELOG.md create mode 100644 providers/openfeature-ofrep-provider/Gemfile create mode 100644 providers/openfeature-ofrep-provider/Gemfile.lock create mode 100644 providers/openfeature-ofrep-provider/README.md create mode 100644 providers/openfeature-ofrep-provider/Rakefile create mode 100755 providers/openfeature-ofrep-provider/bin/rake create mode 100644 providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider.rb create mode 100644 providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/client.rb create mode 100644 providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/configuration.rb create mode 100644 providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/errors.rb create mode 100644 providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/response.rb create mode 100644 providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/version.rb create mode 100644 providers/openfeature-ofrep-provider/openfeature-ofrep-provider.gemspec create mode 100644 providers/openfeature-ofrep-provider/spec/openfeature/ofrep/provider/client_spec.rb create mode 100644 providers/openfeature-ofrep-provider/spec/openfeature/ofrep/provider/configuration_spec.rb create mode 100644 providers/openfeature-ofrep-provider/spec/openfeature/ofrep/provider_spec.rb create mode 100644 providers/openfeature-ofrep-provider/spec/spec_helper.rb diff --git a/.github/component_owners.yml b/.github/component_owners.yml index a3f63cd..3885849 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -9,6 +9,8 @@ components: - thomaspoignant providers/openfeature-meta_provider: - maxveldink + providers/openfeature-ofrep-provider: + - josecolella providers/openfeature-flagsmith-provider: - dabeeeenster - matthewelwell diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 03aeaa4..4454bc3 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -87,6 +87,28 @@ jobs: - name: Lint and test run: bin/rake + test_ofrep_provider: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./providers/openfeature-ofrep-provider + strategy: + matrix: + ruby-version: + - "3.3" + - "3.2" + - "3.1" + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + working-directory: ./providers/openfeature-ofrep-provider + - name: Lint and test + run: bin/rake + test_flipt_provider: runs-on: ubuntu-latest defaults: diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c9fcf26..f843e7b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -3,5 +3,6 @@ "providers/openfeature-flipt-provider": "0.0.2", "providers/openfeature-meta_provider": "0.0.5", "providers/openfeature-go-feature-flag-provider": "0.1.9", + "providers/openfeature-ofrep-provider": "0.1.0", "providers/openfeature-flagsmith-provider": "0.1.1" } diff --git a/providers/openfeature-ofrep-provider/.rspec b/providers/openfeature-ofrep-provider/.rspec new file mode 100644 index 0000000..8c18f1a --- /dev/null +++ b/providers/openfeature-ofrep-provider/.rspec @@ -0,0 +1,2 @@ +--format documentation +--color diff --git a/providers/openfeature-ofrep-provider/.rubocop.yml b/providers/openfeature-ofrep-provider/.rubocop.yml new file mode 100644 index 0000000..feec135 --- /dev/null +++ b/providers/openfeature-ofrep-provider/.rubocop.yml @@ -0,0 +1,5 @@ +inherit_from: ../../shared_config/.rubocop.yml + +inherit_mode: + merge: + - Exclude diff --git a/providers/openfeature-ofrep-provider/CHANGELOG.md b/providers/openfeature-ofrep-provider/CHANGELOG.md new file mode 100644 index 0000000..825c32f --- /dev/null +++ b/providers/openfeature-ofrep-provider/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/providers/openfeature-ofrep-provider/Gemfile b/providers/openfeature-ofrep-provider/Gemfile new file mode 100644 index 0000000..b4e2a20 --- /dev/null +++ b/providers/openfeature-ofrep-provider/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gemspec diff --git a/providers/openfeature-ofrep-provider/Gemfile.lock b/providers/openfeature-ofrep-provider/Gemfile.lock new file mode 100644 index 0000000..4796952 --- /dev/null +++ b/providers/openfeature-ofrep-provider/Gemfile.lock @@ -0,0 +1,162 @@ +PATH + remote: . + specs: + openfeature-ofrep-provider (0.1.0) + faraday-net_http_persistent (~> 2.3) + openfeature-sdk (~> 0.3.1) + +GEM + remote: https://rubygems.org/ + specs: + addressable (2.8.9) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + bigdecimal (4.0.1) + connection_pool (3.0.2) + crack (1.0.1) + bigdecimal + rexml + diff-lcs (1.6.2) + faraday (2.14.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) + faraday-net_http_persistent (2.3.1) + faraday (~> 2.5) + net-http-persistent (>= 4.0.4, < 5) + hashdiff (1.2.1) + json (2.19.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + net-http (0.9.1) + uri (>= 0.11.1) + net-http-persistent (4.0.8) + connection_pool (>= 2.2.4, < 4) + openfeature-sdk (0.3.1) + parallel (1.27.0) + parser (3.3.10.2) + ast (~> 2.4.1) + racc + prism (1.9.0) + public_suffix (7.0.5) + racc (1.8.1) + rainbow (3.1.1) + rake (13.3.1) + regexp_parser (2.11.3) + rexml (3.4.4) + rspec (3.12.0) + rspec-core (~> 3.12.0) + rspec-expectations (~> 3.12.0) + rspec-mocks (~> 3.12.0) + rspec-core (3.12.3) + rspec-support (~> 3.12.0) + rspec-expectations (3.12.4) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-mocks (3.12.7) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-support (3.12.2) + rubocop (1.84.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + ruby-progressbar (1.13.0) + standard (1.54.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.84.0) + standard-custom (~> 1.0.0) + standard-performance (~> 1.8) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.9.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.26.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + webmock (3.26.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + +PLATFORMS + arm64-darwin-24 + ruby + +DEPENDENCIES + openfeature-ofrep-provider! + rake (~> 13.0) + rspec (~> 3.12.0) + rubocop + standard (>= 1.35.1) + standard-performance + webmock + +CHECKSUMS + addressable (2.8.9) sha256=cc154fcbe689711808a43601dee7b980238ce54368d23e127421753e46895485 + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + faraday (2.14.1) sha256=a43cceedc1e39d188f4d2cdd360a8aaa6a11da0c407052e426ba8d3fb42ef61c + faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c + faraday-net_http_persistent (2.3.1) sha256=23ffba37d6a27807a10f033d01918ec958aa73fa6ff0fccfbcd5ce2d2e68fca3 + hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 + json (2.19.0) sha256=bc5202f083618b3af7aba3184146ec9d820f8f6de261838b577173475e499d9a + language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 + net-http-persistent (4.0.8) sha256=ef3de8319d691537b329053fae3a33195f8b070bbbfae8bf1a58c796081960e6 + openfeature-ofrep-provider (0.1.0) + openfeature-sdk (0.3.1) sha256=17a930f52c66ee76a5d20f5bb68cfadbb8d86a4db5b1f99caab8c60ab577f9e5 + parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 + parser (3.3.10.2) sha256=6f60c84aa4bdcedb6d1a2434b738fe8a8136807b6adc8f7f53b97da9bc4e9357 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c + regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rspec (3.12.0) sha256=ccc41799a43509dc0be84070e3f0410ac95cbd480ae7b6c245543eb64162399c + rspec-core (3.12.3) sha256=782189dea5ac28971d2a613ff7bf28b4c4e326ec3f0503c773307671269b6f0d + rspec-expectations (3.12.4) sha256=846ff8968551a775c4c949825d581f394a517bdf78b68feac471282a1b03e75c + rspec-mocks (3.12.7) sha256=b2f10a7879781de9448f28e5dc399277d4eac14d577b450e4c04b7897fe4c6a8 + rspec-support (3.12.2) sha256=de719abdc9d03842c96052be4a2dab8d7fd9314d0b2488b0f755008d071b761d + rubocop (1.84.2) sha256=5692cea54168f3dc8cb79a6fe95c5424b7ea893c707ad7a4307b0585e88dbf5f + rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd + rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + standard (1.54.0) sha256=7a4b08f83d9893083c8f03bc486f0feeb6a84d48233b40829c03ef4767ea0100 + standard-custom (1.0.2) sha256=424adc84179a074f1a2a309bb9cf7cd6bfdb2b6541f20c6bf9436c0ba22a652b + standard-performance (1.9.0) sha256=49483d31be448292951d80e5e67cdcb576c2502103c7b40aec6f1b6e9c88e3f2 + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + webmock (3.26.1) sha256=4f696fb57c90a827c20aadb2d4f9058bbff10f7f043bd0d4c3f58791143b1cd7 + +BUNDLED WITH + 4.0.6 diff --git a/providers/openfeature-ofrep-provider/README.md b/providers/openfeature-ofrep-provider/README.md new file mode 100644 index 0000000..02146b1 --- /dev/null +++ b/providers/openfeature-ofrep-provider/README.md @@ -0,0 +1,59 @@ +# OpenFeature OFREP Provider for Ruby + +An [OpenFeature](https://openfeature.dev) provider for [OFREP](https://openfeature.dev/docs/reference/technologies/remote-evaluation-protocol) (OpenFeature Remote Evaluation Protocol) compliant feature flag servers. + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'openfeature-ofrep-provider' +``` + +And then execute: + +```bash +bundle install +``` + +Or install it yourself as: + +```bash +gem install openfeature-ofrep-provider +``` + +## Usage + +```ruby +require "openfeature/ofrep/provider" + +configuration = OpenFeature::OFREP::Configuration.new( + base_url: "http://localhost:8080", + headers: {"Authorization" => "Bearer my-token"}, + timeout: 10 +) + +provider = OpenFeature::OFREP::Provider.new(configuration: configuration) + +OpenFeature::SDK.configure do |config| + config.set_provider(provider) +end + +client = OpenFeature::SDK.build_client + +result = client.fetch_boolean_value( + flag_key: "my-flag", + default_value: false, + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123") +) +``` + +## Configuration + +| Option | Type | Default | Description | +|------------|---------|---------|--------------------------------------------------| +| `base_url` | String | *required* | Base URL of the OFREP-compliant server | +| `headers` | Hash | `{}` | Custom headers (e.g., for authentication) | +| `timeout` | Integer | `10` | HTTP timeout in seconds | + +## Version 0.1.0 diff --git a/providers/openfeature-ofrep-provider/Rakefile b/providers/openfeature-ofrep-provider/Rakefile new file mode 100644 index 0000000..85f5f4d --- /dev/null +++ b/providers/openfeature-ofrep-provider/Rakefile @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +require "standard/rake" + +task default: %i[standard spec] diff --git a/providers/openfeature-ofrep-provider/bin/rake b/providers/openfeature-ofrep-provider/bin/rake new file mode 100755 index 0000000..4eb7d7b --- /dev/null +++ b/providers/openfeature-ofrep-provider/bin/rake @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'rake' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +bundle_binstub = File.expand_path("bundle", __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("rake", "rake") diff --git a/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider.rb b/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider.rb new file mode 100644 index 0000000..0453121 --- /dev/null +++ b/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "openfeature/ofrep/provider/configuration" +require "openfeature/ofrep/provider/client" +require "openfeature/ofrep/provider/response" +require "openfeature/ofrep/provider/errors" +require "openfeature/ofrep/provider/version" + +module OpenFeature + module OFREP + class Provider + PROVIDER_NAME = "OFREP Provider" + attr_reader :metadata + + def initialize(configuration:) + @metadata = SDK::Provider::ProviderMetadata.new(name: PROVIDER_NAME) + @configuration = configuration + @client = Client.new(configuration: configuration) + end + + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) + evaluate(flag_key: flag_key, default_value: default_value, evaluation_context: evaluation_context, allowed_classes: [TrueClass, FalseClass]) + end + + def fetch_string_value(flag_key:, default_value:, evaluation_context: nil) + evaluate(flag_key: flag_key, default_value: default_value, evaluation_context: evaluation_context, allowed_classes: [String]) + end + + def fetch_number_value(flag_key:, default_value:, evaluation_context: nil) + evaluate(flag_key: flag_key, default_value: default_value, evaluation_context: evaluation_context, allowed_classes: [Integer, Float]) + end + + def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil) + evaluate(flag_key: flag_key, default_value: default_value, evaluation_context: evaluation_context, allowed_classes: [Integer]) + end + + def fetch_float_value(flag_key:, default_value:, evaluation_context: nil) + evaluate(flag_key: flag_key, default_value: default_value, evaluation_context: evaluation_context, allowed_classes: [Float]) + end + + def fetch_object_value(flag_key:, default_value:, evaluation_context: nil) + evaluate(flag_key: flag_key, default_value: default_value, evaluation_context: evaluation_context, allowed_classes: [Array, Hash]) + end + + private + + def evaluate(flag_key:, default_value:, allowed_classes:, evaluation_context: nil) + evaluation_context = OpenFeature::SDK::EvaluationContext.new if evaluation_context.nil? + validate_parameters(flag_key, evaluation_context) + + parsed_response = @client.evaluate(flag_key: flag_key, evaluation_context: evaluation_context) + + if parsed_response.error? + return SDK::Provider::ResolutionDetails.new( + value: default_value, + error_code: parsed_response.error_code, + error_message: parsed_response.error_details, + reason: parsed_response.reason + ) + end + + if parsed_response.reason == SDK::Provider::Reason::DISABLED + return SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::DISABLED + ) + end + + unless allowed_classes.include?(parsed_response.value.class) + return SDK::Provider::ResolutionDetails.new( + value: default_value, + error_code: SDK::Provider::ErrorCode::TYPE_MISMATCH, + error_message: "flag type #{parsed_response.value.class} does not match allowed types #{allowed_classes}", + reason: SDK::Provider::Reason::ERROR + ) + end + + SDK::Provider::ResolutionDetails.new( + value: parsed_response.value, + reason: parsed_response.reason, + variant: parsed_response.variant, + flag_metadata: parsed_response.metadata + ) + rescue UnauthorizedError, + InvalidOptionError, + FlagNotFoundError, + InternalServerError => e + SDK::Provider::ResolutionDetails.new( + value: default_value, + error_code: e.error_code, + error_message: e.error_message, + reason: SDK::Provider::Reason::ERROR + ) + end + + def validate_parameters(flag_key, evaluation_context) + if evaluation_context.nil? || evaluation_context.targeting_key.nil? || evaluation_context.targeting_key.empty? + raise InvalidOptionError.new(SDK::Provider::ErrorCode::INVALID_CONTEXT, "invalid evaluation context provided") + end + + if flag_key.nil? || flag_key.empty? + raise InvalidOptionError.new(SDK::Provider::ErrorCode::GENERAL, "invalid flag key provided") + end + end + end + end +end diff --git a/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/client.rb b/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/client.rb new file mode 100644 index 0000000..8135e5e --- /dev/null +++ b/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/client.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require "json" +require "open_feature/sdk" +require "faraday/net_http_persistent" +require_relative "errors" +require_relative "response" + +module OpenFeature + module OFREP + class Client + def initialize(configuration:) + @configuration = configuration + request_options = {timeout: configuration.timeout} + @faraday_connection = Faraday.new( + url: configuration.base_url, + headers: build_headers, + request: request_options + ) do |f| + f.adapter :net_http_persistent do |http| + http.idle_timeout = 30 + end + end + end + + def evaluate(flag_key:, evaluation_context:) + check_retry_after + request = evaluation_request(evaluation_context) + + response = @faraday_connection.post("/ofrep/v1/evaluate/flags/#{flag_key}") do |req| + req.body = request.to_json + end + + case response.status + when 200 + parse_success_response(response) + when 400 + parse_error_response(response) + when 401, 403 + raise OpenFeature::OFREP::UnauthorizedError.new(response) + when 404 + raise OpenFeature::OFREP::FlagNotFoundError.new(response, flag_key) + when 429 + parse_retry_later_header(response) + raise OpenFeature::OFREP::RateLimited.new(response) + else + raise OpenFeature::OFREP::InternalServerError.new(response) + end + end + + private + + def build_headers + {"Content-Type" => "application/json"}.merge(@configuration.headers || {}) + end + + def evaluation_request(evaluation_context) + ctx = evaluation_context || OpenFeature::SDK::EvaluationContext.new + fields = ctx.fields.dup + fields["targetingKey"] = ctx.targeting_key + fields.delete("targeting_key") + + {context: fields} + end + + def check_retry_after + lock = (@retry_lock ||= Mutex.new) + lock.synchronize do + return if @retry_after.nil? + if Time.now < @retry_after + raise OpenFeature::OFREP::RateLimited.new(nil) + else + @retry_after = nil + end + end + end + + def parse_error_response(response) + required_keys = %w[key error_code] + parsed = JSON.parse(response.body) + + missing_keys = required_keys - parsed.keys + unless missing_keys.empty? + raise OpenFeature::OFREP::ParseError.new(response) + end + + OpenFeature::OFREP::Response.new( + value: nil, + key: parsed["key"], + reason: SDK::Provider::Reason::ERROR, + variant: nil, + error_code: error_code_mapper(parsed["error_code"]), + error_details: parsed["error_details"], + metadata: nil + ) + end + + def parse_success_response(response) + required_keys = %w[key value reason variant] + parsed = JSON.parse(response.body) + + missing_keys = required_keys - parsed.keys + unless missing_keys.empty? + raise OpenFeature::OFREP::ParseError.new(response) + end + + OpenFeature::OFREP::Response.new( + value: parsed["value"], + key: parsed["key"], + reason: reason_mapper(parsed["reason"]), + variant: parsed["variant"], + error_code: nil, + error_details: nil, + metadata: parsed["metadata"] + ) + end + + def reason_mapper(reason_str) + reason_str = reason_str.upcase + reason_map = { + "STATIC" => SDK::Provider::Reason::STATIC, + "DEFAULT" => SDK::Provider::Reason::DEFAULT, + "TARGETING_MATCH" => SDK::Provider::Reason::TARGETING_MATCH, + "SPLIT" => SDK::Provider::Reason::SPLIT, + "CACHED" => SDK::Provider::Reason::CACHED, + "DISABLED" => SDK::Provider::Reason::DISABLED, + "UNKNOWN" => SDK::Provider::Reason::UNKNOWN, + "STALE" => SDK::Provider::Reason::STALE, + "ERROR" => SDK::Provider::Reason::ERROR + } + reason_map[reason_str] || SDK::Provider::Reason::UNKNOWN + end + + def error_code_mapper(error_code_str) + error_code_str = error_code_str.upcase + error_code_map = { + "PROVIDER_NOT_READY" => SDK::Provider::ErrorCode::PROVIDER_NOT_READY, + "FLAG_NOT_FOUND" => SDK::Provider::ErrorCode::FLAG_NOT_FOUND, + "PARSE_ERROR" => SDK::Provider::ErrorCode::PARSE_ERROR, + "TYPE_MISMATCH" => SDK::Provider::ErrorCode::TYPE_MISMATCH, + "TARGETING_KEY_MISSING" => SDK::Provider::ErrorCode::TARGETING_KEY_MISSING, + "INVALID_CONTEXT" => SDK::Provider::ErrorCode::INVALID_CONTEXT, + "GENERAL" => SDK::Provider::ErrorCode::GENERAL + } + error_code_map[error_code_str] || SDK::Provider::ErrorCode::GENERAL + end + + def parse_retry_later_header(response) + retry_after = response["Retry-After"] + return nil if retry_after.nil? + + begin + next_retry_time = + if /^\d+$/.match?(retry_after) + Time.now + Integer(retry_after) + else + Time.httpdate(retry_after) + end + lock = (@retry_lock ||= Mutex.new) + lock.synchronize do + @retry_after = [@retry_after, next_retry_time].compact.max + end + rescue ArgumentError + nil + end + end + end + end +end diff --git a/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/configuration.rb b/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/configuration.rb new file mode 100644 index 0000000..1f3fa01 --- /dev/null +++ b/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/configuration.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "uri" + +module OpenFeature + module OFREP + class Configuration + attr_reader :base_url, :headers, :timeout + + def initialize(base_url:, headers: {}, timeout: 10) + validate_base_url(base_url) + @base_url = base_url + @headers = headers + @timeout = timeout + end + + private + + def validate_base_url(base_url) + raise ArgumentError, "base_url is required" if base_url.nil? || base_url.empty? + + uri = URI.parse(base_url) + raise ArgumentError, "Invalid URL for base_url: #{base_url}" unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) + rescue URI::InvalidURIError + raise ArgumentError, "Invalid URL for base_url: #{base_url}" + end + end + end +end diff --git a/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/errors.rb b/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/errors.rb new file mode 100644 index 0000000..e93dfdd --- /dev/null +++ b/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/errors.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "open_feature/sdk/provider/error_code" + +module OpenFeature + module OFREP + class FlagNotFoundError < StandardError + attr_reader :response, :error_code, :error_message + + def initialize(response, flag_key) + error_message = "Flag not found: #{flag_key}" + @response = response + @error_code = SDK::Provider::ErrorCode::FLAG_NOT_FOUND + @error_message = error_message + super(error_message) + end + end + + class InternalServerError < StandardError + attr_reader :response, :error_code, :error_message + + def initialize(response) + error_message = "Internal Server Error" + @response = response + @error_code = SDK::Provider::ErrorCode::GENERAL + @error_message = error_message + super(error_message) + end + end + + class InvalidOptionError < StandardError + attr_reader :error_code, :error_message + + def initialize(error_code, error_message) + @error_code = error_code + @error_message = error_message + super(error_message) + end + end + + class UnauthorizedError < StandardError + attr_reader :response, :error_code, :error_message + + def initialize(response) + error_message = "unauthorized" + @response = response + @error_code = SDK::Provider::ErrorCode::GENERAL + @error_message = error_message + super(error_message) + end + end + + class ParseError < StandardError + attr_reader :response, :error_code, :error_message + + def initialize(response) + error_message = "Parse error" + @response = response + @error_code = SDK::Provider::ErrorCode::PARSE_ERROR + @error_message = error_message + super(error_message) + end + end + + class RateLimited < StandardError + attr_reader :response, :error_code, :error_message + + def initialize(response) + error_message = response.nil? ? "Rate limited" : "Rate limited: " + response["Retry-After"].to_s + @response = response + @error_code = SDK::Provider::ErrorCode::GENERAL + @error_message = error_message + super(error_message) + end + end + end +end diff --git a/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/response.rb b/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/response.rb new file mode 100644 index 0000000..ddb37ea --- /dev/null +++ b/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/response.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module OpenFeature + module OFREP + class Response + attr_reader :value, :key, :reason, :variant, :error_code, :error_details, :metadata + + def initialize(value:, key:, reason:, variant:, error_code:, error_details:, metadata:) + @value = value + @key = key + @reason = reason + @variant = variant + @error_code = error_code + @error_details = error_details + @metadata = metadata + end + + def error? + !@error_code.nil? && !@error_code.empty? + end + + def eql?(other) + return false unless other.is_a?(OpenFeature::OFREP::Response) + key == other.key && + value == other.value && + reason == other.reason && + variant == other.variant && + error_code == other.error_code && + error_details == other.error_details && + metadata == other.metadata + end + end + end +end diff --git a/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/version.rb b/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/version.rb new file mode 100644 index 0000000..c2d50c3 --- /dev/null +++ b/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module OpenFeature + module OFREP + OFREP_PROVIDER_VERSION = "0.1.0" + end +end diff --git a/providers/openfeature-ofrep-provider/openfeature-ofrep-provider.gemspec b/providers/openfeature-ofrep-provider/openfeature-ofrep-provider.gemspec new file mode 100644 index 0000000..da40767 --- /dev/null +++ b/providers/openfeature-ofrep-provider/openfeature-ofrep-provider.gemspec @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative "lib/openfeature/ofrep/provider/version" + +Gem::Specification.new do |spec| + spec.name = "openfeature-ofrep-provider" + spec.version = OpenFeature::OFREP::OFREP_PROVIDER_VERSION + spec.authors = ["OpenFeature"] + spec.email = ["openfeature@openfeature.dev"] + + spec.summary = "The OFREP provider for the OpenFeature Ruby SDK" + spec.description = "A vendor-neutral OFREP (OpenFeature Remote Evaluation Protocol) provider for the OpenFeature Ruby SDK" + spec.homepage = "https://github.com/open-feature/ruby-sdk-contrib/tree/main/providers/openfeature-ofrep-provider" + spec.license = "Apache-2.0" + spec.required_ruby_version = ">= 3.1" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = "https://github.com/open-feature/ruby-sdk-contrib/tree/main/providers/openfeature-ofrep-provider" + spec.metadata["changelog_uri"] = "https://github.com/open-feature/ruby-sdk-contrib/blob/main/providers/openfeature-ofrep-provider/CHANGELOG.md" + spec.metadata["bug_tracker_uri"] = "https://github.com/open-feature/ruby-sdk-contrib/issues" + spec.metadata["documentation_uri"] = "https://openfeature.dev/docs/reference/technologies/server/ruby" + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + spec.files = Dir.chdir(File.expand_path(__dir__)) do + `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + spec.add_runtime_dependency "openfeature-sdk", "~> 0.3.1" + spec.add_runtime_dependency "faraday-net_http_persistent", "~> 2.3" + + spec.add_development_dependency "rake", "~> 13.0" + spec.add_development_dependency "rspec", "~> 3.12.0" + spec.add_development_dependency "standard", ">= 1.35.1" + spec.add_development_dependency "rubocop" + spec.add_development_dependency "standard-performance" + spec.add_development_dependency "webmock" +end diff --git a/providers/openfeature-ofrep-provider/spec/openfeature/ofrep/provider/client_spec.rb b/providers/openfeature-ofrep-provider/spec/openfeature/ofrep/provider/client_spec.rb new file mode 100644 index 0000000..0a94314 --- /dev/null +++ b/providers/openfeature-ofrep-provider/spec/openfeature/ofrep/provider/client_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe OpenFeature::OFREP::Client do + subject(:client) do + config = OpenFeature::OFREP::Configuration.new(base_url: "http://localhost:8080") + described_class.new(configuration: config) + end + + let(:default_evaluation_context) do + OpenFeature::SDK::EvaluationContext.new( + targeting_key: "4f433951-4c8c-42b3-9f18-8c9a5ed8e9eb", + company: "OFREP", + firstname: "John", + lastname: "Doe" + ) + end + + context "#evaluate" do + it "returns a valid response if 200" do + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/my_flag") + .to_return(status: 200, body: + { + key: "my_flag", + metadata: {"source" => "database"}, + value: true, + reason: "TARGETING_MATCH", + variant: "variantA" + }.to_json) + + got = client.evaluate(flag_key: "my_flag", evaluation_context: default_evaluation_context) + want = OpenFeature::OFREP::Response.new( + key: "my_flag", + value: true, + reason: OpenFeature::SDK::Provider::Reason::TARGETING_MATCH, + variant: "variantA", + error_code: nil, + error_details: nil, + metadata: {"source" => "database"} + ) + expect(got).to eql(want) + end + + it "returns an error response if 400" do + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/my_flag") + .to_return(status: 400, body: + { + key: "my_flag", + error_code: "TYPE_MISMATCH", + error_details: "expected type: boolean, got: string" + }.to_json) + + got = client.evaluate(flag_key: "my_flag", evaluation_context: default_evaluation_context) + want = OpenFeature::OFREP::Response.new( + key: "my_flag", + value: nil, + reason: OpenFeature::SDK::Provider::Reason::ERROR, + variant: nil, + error_code: OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH, + error_details: "expected type: boolean, got: string", + metadata: nil + ) + expect(got).to eql(want) + end + + it "raises an error if not authorized (401)" do + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/my_flag") + .to_return(status: 401) + + expect { + client.evaluate(flag_key: "my_flag", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::OFREP::UnauthorizedError) + end + + it "raises an error if not authorized (403)" do + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/my_flag") + .to_return(status: 403) + + expect { + client.evaluate(flag_key: "my_flag", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::OFREP::UnauthorizedError) + end + + it "raises an error if flag not found (404)" do + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/does-not-exist") + .to_return(status: 404) + + expect { + client.evaluate(flag_key: "does-not-exist", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::OFREP::FlagNotFoundError) + end + + it "raises an error if rate limited (429)" do + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/my_flag") + .to_return(status: 429) + + expect { + client.evaluate(flag_key: "my_flag", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::OFREP::RateLimited) + end + + it "raises an error if server error (500)" do + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/my_flag") + .to_return(status: 500) + + expect { + client.evaluate(flag_key: "my_flag", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::OFREP::InternalServerError) + end + + it "raises a parse error if 200 and missing required keys" do + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/my_flag") + .to_return(status: 200, body: + { + key: "my_flag", + reason: "TARGETING_MATCH", + variant: "variantA" + }.to_json) + + expect { + client.evaluate(flag_key: "my_flag", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::OFREP::ParseError) + end + + it "raises a parse error if 400 and missing required keys" do + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/my_flag") + .to_return(status: 400, body: + { + error_code: "TYPE_MISMATCH" + }.to_json) + + expect { + client.evaluate(flag_key: "my_flag", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::OFREP::ParseError) + end + + it "blocks subsequent calls when rate limited with Retry-After header (integer)" do + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/my_flag") + .to_return(status: 429, headers: {"Retry-After" => "10"}) + + expect { + client.evaluate(flag_key: "my_flag", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::OFREP::RateLimited) + + expect { + client.evaluate(flag_key: "other_flag", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::OFREP::RateLimited) + end + + it "allows calls again after Retry-After period expires (integer)" do + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/my_flag") + .to_return(status: 429, headers: {"Retry-After" => "1"}) + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/other_flag") + .to_return(status: 200, body: + { + key: "other_flag", + value: true, + reason: "TARGETING_MATCH", + variant: "variantA" + }.to_json) + + expect { + client.evaluate(flag_key: "my_flag", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::OFREP::RateLimited) + + sleep(1.1) + + expect { + client.evaluate(flag_key: "other_flag", evaluation_context: default_evaluation_context) + }.not_to raise_error + end + + it "blocks subsequent calls when rate limited with Retry-After header (date)" do + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/my_flag") + .to_return(status: 429, headers: {"Retry-After" => (Time.now + 1).httpdate}) + + expect { + client.evaluate(flag_key: "my_flag", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::OFREP::RateLimited) + + expect { + client.evaluate(flag_key: "other_flag", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::OFREP::RateLimited) + end + end +end diff --git a/providers/openfeature-ofrep-provider/spec/openfeature/ofrep/provider/configuration_spec.rb b/providers/openfeature-ofrep-provider/spec/openfeature/ofrep/provider/configuration_spec.rb new file mode 100644 index 0000000..b6b38f6 --- /dev/null +++ b/providers/openfeature-ofrep-provider/spec/openfeature/ofrep/provider/configuration_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe OpenFeature::OFREP::Configuration do + context "#initialization" do + it "creates a valid configuration with required params" do + config = described_class.new(base_url: "http://localhost:8080") + expect(config.base_url).to eq("http://localhost:8080") + expect(config.headers).to eq({}) + expect(config.timeout).to eq(10) + end + + it "creates a valid configuration with all params" do + config = described_class.new( + base_url: "https://example.com", + headers: {"Authorization" => "Bearer token"}, + timeout: 30 + ) + expect(config.base_url).to eq("https://example.com") + expect(config.headers).to eq({"Authorization" => "Bearer token"}) + expect(config.timeout).to eq(30) + end + + it "raises if base_url is nil" do + expect { described_class.new(base_url: nil) }.to raise_error(ArgumentError, "base_url is required") + end + + it "raises if base_url is empty" do + expect { described_class.new(base_url: "") }.to raise_error(ArgumentError, "base_url is required") + end + + it "raises if base_url is invalid" do + expect { described_class.new(base_url: "invalid_url") }.to raise_error(ArgumentError, "Invalid URL for base_url: invalid_url") + end + + it "raises if base_url is not http" do + expect { described_class.new(base_url: "ftp://example.com") }.to raise_error(ArgumentError, "Invalid URL for base_url: ftp://example.com") + end + end +end diff --git a/providers/openfeature-ofrep-provider/spec/openfeature/ofrep/provider_spec.rb b/providers/openfeature-ofrep-provider/spec/openfeature/ofrep/provider_spec.rb new file mode 100644 index 0000000..65deef3 --- /dev/null +++ b/providers/openfeature-ofrep-provider/spec/openfeature/ofrep/provider_spec.rb @@ -0,0 +1,475 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe OpenFeature::OFREP::Provider do + subject(:ofrep_provider) do + configuration = OpenFeature::OFREP::Configuration.new(base_url: "http://localhost:8080") + described_class.new(configuration: configuration) + end + + context "#metadata" do + it "metadata name is defined" do + expect(ofrep_provider).to respond_to(:metadata) + expect(ofrep_provider.metadata).to respond_to(:name) + expect(ofrep_provider.metadata.name).to eq("OFREP Provider") + end + end + + context "#fetch_boolean_value" do + it "returns the value of the flag" do + test_name = RSpec.current_example.description + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/boolean_flag") + .to_return(status: 200, body: + { + key: "boolean_flag", + metadata: {"source" => "database"}, + value: true, + reason: "TARGETING_MATCH", + variant: "variantA" + }.to_json) + + OpenFeature::SDK.configure do |config| + config.set_provider(ofrep_provider, domain: test_name) + end + client = OpenFeature::SDK.build_client(domain: test_name) + got = client.fetch_boolean_details( + flag_key: "boolean_flag", + default_value: false, + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123") + ) + want = OpenFeature::SDK::EvaluationDetails.new( + flag_key: "boolean_flag", + resolution_details: OpenFeature::SDK::Provider::ResolutionDetails.new( + value: true, + reason: OpenFeature::SDK::Provider::Reason::TARGETING_MATCH, + variant: "variantA", + flag_metadata: {"source" => "database"} + ) + ) + expect(got).to eql(want) + end + + it "returns the default value if flag is not the right type" do + test_name = RSpec.current_example.description + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/boolean_flag") + .to_return(status: 200, body: + { + key: "boolean_flag", + value: "not_a_boolean", + reason: "TARGETING_MATCH", + variant: "variantA" + }.to_json) + + OpenFeature::SDK.configure do |config| + config.set_provider(ofrep_provider, domain: test_name) + end + client = OpenFeature::SDK.build_client(domain: test_name) + got = client.fetch_boolean_details( + flag_key: "boolean_flag", + default_value: false, + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123") + ) + want = OpenFeature::SDK::EvaluationDetails.new( + flag_key: "boolean_flag", + resolution_details: OpenFeature::SDK::Provider::ResolutionDetails.new( + value: false, + reason: OpenFeature::SDK::Provider::Reason::ERROR, + error_code: OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH, + error_message: "flag type String does not match allowed types [TrueClass, FalseClass]" + ) + ) + expect(got).to eql(want) + end + + it "returns the default value if error send by the API (http code 403)" do + test_name = RSpec.current_example.description + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/boolean_flag") + .to_return(status: 403) + + OpenFeature::SDK.configure do |config| + config.set_provider(ofrep_provider, domain: test_name) + end + client = OpenFeature::SDK.build_client(domain: test_name) + got = client.fetch_boolean_details( + flag_key: "boolean_flag", + default_value: false, + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123") + ) + want = OpenFeature::SDK::EvaluationDetails.new( + flag_key: "boolean_flag", + resolution_details: OpenFeature::SDK::Provider::ResolutionDetails.new( + value: false, + reason: OpenFeature::SDK::Provider::Reason::ERROR, + error_code: OpenFeature::SDK::Provider::ErrorCode::GENERAL, + error_message: "unauthorized" + ) + ) + expect(got).to eql(want) + end + + it "returns the default value if error send by the API (http code 400)" do + test_name = RSpec.current_example.description + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/boolean_flag") + .to_return(status: 400, body: + { + key: "boolean_flag", + error_code: "INVALID_CONTEXT" + }.to_json) + + OpenFeature::SDK.configure do |config| + config.set_provider(ofrep_provider, domain: test_name) + end + client = OpenFeature::SDK.build_client(domain: test_name) + got = client.fetch_boolean_details( + flag_key: "boolean_flag", + default_value: false, + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123") + ) + want = OpenFeature::SDK::EvaluationDetails.new( + flag_key: "boolean_flag", + resolution_details: OpenFeature::SDK::Provider::ResolutionDetails.new( + value: false, + reason: OpenFeature::SDK::Provider::Reason::ERROR, + error_code: OpenFeature::SDK::Provider::ErrorCode::INVALID_CONTEXT + ) + ) + expect(got).to eql(want) + end + + it "returns default value if no evaluation context" do + eval_result = ofrep_provider.fetch_boolean_value(flag_key: "flag_key", default_value: true, evaluation_context: nil) + want = OpenFeature::SDK::Provider::ResolutionDetails.new( + value: true, + error_code: OpenFeature::SDK::Provider::ErrorCode::INVALID_CONTEXT, + error_message: "invalid evaluation context provided", + reason: OpenFeature::SDK::Provider::Reason::ERROR + ) + expect(eval_result).to eql(want) + end + + it "returns default value if evaluation context has empty targeting key" do + eval_result = ofrep_provider.fetch_boolean_value( + flag_key: "flag_key", + default_value: true, + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "") + ) + want = OpenFeature::SDK::Provider::ResolutionDetails.new( + value: true, + error_code: OpenFeature::SDK::Provider::ErrorCode::INVALID_CONTEXT, + error_message: "invalid evaluation context provided", + reason: OpenFeature::SDK::Provider::Reason::ERROR + ) + expect(eval_result).to eql(want) + end + + it "returns default value if flag_key is nil" do + eval_result = ofrep_provider.fetch_boolean_value( + flag_key: nil, + default_value: true, + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123") + ) + want = OpenFeature::SDK::Provider::ResolutionDetails.new( + value: true, + error_code: OpenFeature::SDK::Provider::ErrorCode::GENERAL, + error_message: "invalid flag key provided", + reason: OpenFeature::SDK::Provider::Reason::ERROR + ) + expect(eval_result).to eql(want) + end + + it "returns default value if flag_key is empty" do + eval_result = ofrep_provider.fetch_boolean_value( + flag_key: "", + default_value: true, + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123") + ) + want = OpenFeature::SDK::Provider::ResolutionDetails.new( + value: true, + error_code: OpenFeature::SDK::Provider::ErrorCode::GENERAL, + error_message: "invalid flag key provided", + reason: OpenFeature::SDK::Provider::Reason::ERROR + ) + expect(eval_result).to eql(want) + end + + it "returns the default value if the reason is DISABLED" do + test_name = RSpec.current_example.description + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/boolean_flag") + .to_return(status: 200, body: + { + key: "boolean_flag", + value: true, + reason: "DISABLED", + variant: "variantA" + }.to_json) + + OpenFeature::SDK.configure do |config| + config.set_provider(ofrep_provider, domain: test_name) + end + client = OpenFeature::SDK.build_client(domain: test_name) + got = client.fetch_boolean_details( + flag_key: "boolean_flag", + default_value: false, + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123") + ) + want = OpenFeature::SDK::EvaluationDetails.new( + flag_key: "boolean_flag", + resolution_details: OpenFeature::SDK::Provider::ResolutionDetails.new( + value: false, + reason: OpenFeature::SDK::Provider::Reason::DISABLED + ) + ) + expect(got).to eql(want) + end + + it "returns error for 404 flag not found" do + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/missing_flag") + .to_return(status: 404) + + eval_result = ofrep_provider.fetch_boolean_value( + flag_key: "missing_flag", + default_value: false, + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123") + ) + want = OpenFeature::SDK::Provider::ResolutionDetails.new( + value: false, + error_code: OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND, + error_message: "Flag not found: missing_flag", + reason: OpenFeature::SDK::Provider::Reason::ERROR + ) + expect(eval_result).to eql(want) + end + + it "returns error for 401 unauthorized" do + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/boolean_flag") + .to_return(status: 401) + + eval_result = ofrep_provider.fetch_boolean_value( + flag_key: "boolean_flag", + default_value: true, + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123") + ) + want = OpenFeature::SDK::Provider::ResolutionDetails.new( + value: true, + error_code: OpenFeature::SDK::Provider::ErrorCode::GENERAL, + error_message: "unauthorized", + reason: OpenFeature::SDK::Provider::Reason::ERROR + ) + expect(eval_result).to eql(want) + end + + it "returns error for 500 server error" do + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/boolean_flag") + .to_return(status: 500) + + eval_result = ofrep_provider.fetch_boolean_value( + flag_key: "boolean_flag", + default_value: false, + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123") + ) + want = OpenFeature::SDK::Provider::ResolutionDetails.new( + value: false, + error_code: OpenFeature::SDK::Provider::ErrorCode::GENERAL, + error_message: "Internal Server Error", + reason: OpenFeature::SDK::Provider::Reason::ERROR + ) + expect(eval_result).to eql(want) + end + end + + context "#fetch_string_value" do + it "returns the value of the flag" do + test_name = RSpec.current_example.description + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/string_flag") + .to_return(status: 200, body: + { + key: "string_flag", + value: "hello", + reason: "TARGETING_MATCH", + variant: "variantA" + }.to_json) + + OpenFeature::SDK.configure do |config| + config.set_provider(ofrep_provider, domain: test_name) + end + client = OpenFeature::SDK.build_client(domain: test_name) + got = client.fetch_string_details( + flag_key: "string_flag", + default_value: "default", + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123") + ) + want = OpenFeature::SDK::EvaluationDetails.new( + flag_key: "string_flag", + resolution_details: OpenFeature::SDK::Provider::ResolutionDetails.new( + value: "hello", + variant: "variantA", + reason: OpenFeature::SDK::Provider::Reason::TARGETING_MATCH + ) + ) + expect(got).to eql(want) + end + + it "returns the default value if flag is not the right type" do + test_name = RSpec.current_example.description + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/string_flag") + .to_return(status: 200, body: + { + key: "string_flag", + value: 42, + reason: "TARGETING_MATCH", + variant: "variantA" + }.to_json) + + OpenFeature::SDK.configure do |config| + config.set_provider(ofrep_provider, domain: test_name) + end + client = OpenFeature::SDK.build_client(domain: test_name) + got = client.fetch_string_details( + flag_key: "string_flag", + default_value: "default", + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123") + ) + want = OpenFeature::SDK::EvaluationDetails.new( + flag_key: "string_flag", + resolution_details: OpenFeature::SDK::Provider::ResolutionDetails.new( + value: "default", + reason: OpenFeature::SDK::Provider::Reason::ERROR, + error_code: OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH, + error_message: "flag type Integer does not match allowed types [String]" + ) + ) + expect(got).to eql(want) + end + end + + context "#fetch_number_value" do + it "returns the value of the flag" do + test_name = RSpec.current_example.description + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/number_flag") + .to_return(status: 200, body: + { + key: "number_flag", + value: 42, + reason: "TARGETING_MATCH", + variant: "variantA" + }.to_json) + + OpenFeature::SDK.configure do |config| + config.set_provider(ofrep_provider, domain: test_name) + end + client = OpenFeature::SDK.build_client(domain: test_name) + got = client.fetch_number_details( + flag_key: "number_flag", + default_value: 0, + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123") + ) + want = OpenFeature::SDK::EvaluationDetails.new( + flag_key: "number_flag", + resolution_details: OpenFeature::SDK::Provider::ResolutionDetails.new( + value: 42, + variant: "variantA", + reason: OpenFeature::SDK::Provider::Reason::TARGETING_MATCH + ) + ) + expect(got).to eql(want) + end + + it "returns the default value if flag is not the right type" do + test_name = RSpec.current_example.description + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/number_flag") + .to_return(status: 200, body: + { + key: "number_flag", + value: "not_a_number", + reason: "TARGETING_MATCH", + variant: "variantA" + }.to_json) + + OpenFeature::SDK.configure do |config| + config.set_provider(ofrep_provider, domain: test_name) + end + client = OpenFeature::SDK.build_client(domain: test_name) + got = client.fetch_number_details( + flag_key: "number_flag", + default_value: 0, + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123") + ) + want = OpenFeature::SDK::EvaluationDetails.new( + flag_key: "number_flag", + resolution_details: OpenFeature::SDK::Provider::ResolutionDetails.new( + value: 0, + reason: OpenFeature::SDK::Provider::Reason::ERROR, + error_code: OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH, + error_message: "flag type String does not match allowed types [Integer, Float]" + ) + ) + expect(got).to eql(want) + end + end + + context "#fetch_object_value" do + it "returns the value of the flag" do + test_name = RSpec.current_example.description + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/object_flag") + .to_return(status: 200, body: + { + key: "object_flag", + value: {"color" => "blue"}, + reason: "TARGETING_MATCH", + variant: "variantA" + }.to_json) + + OpenFeature::SDK.configure do |config| + config.set_provider(ofrep_provider, domain: test_name) + end + client = OpenFeature::SDK.build_client(domain: test_name) + got = client.fetch_object_details( + flag_key: "object_flag", + default_value: {}, + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123") + ) + want = OpenFeature::SDK::EvaluationDetails.new( + flag_key: "object_flag", + resolution_details: OpenFeature::SDK::Provider::ResolutionDetails.new( + value: {"color" => "blue"}, + variant: "variantA", + reason: OpenFeature::SDK::Provider::Reason::TARGETING_MATCH + ) + ) + expect(got).to eql(want) + end + + it "returns the default value if flag is not the right type" do + test_name = RSpec.current_example.description + stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/object_flag") + .to_return(status: 200, body: + { + key: "object_flag", + value: "not_an_object", + reason: "TARGETING_MATCH", + variant: "variantA" + }.to_json) + + OpenFeature::SDK.configure do |config| + config.set_provider(ofrep_provider, domain: test_name) + end + client = OpenFeature::SDK.build_client(domain: test_name) + got = client.fetch_object_details( + flag_key: "object_flag", + default_value: {"fallback" => true}, + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123") + ) + want = OpenFeature::SDK::EvaluationDetails.new( + flag_key: "object_flag", + resolution_details: OpenFeature::SDK::Provider::ResolutionDetails.new( + value: {"fallback" => true}, + reason: OpenFeature::SDK::Provider::Reason::ERROR, + error_code: OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH, + error_message: "flag type String does not match allowed types [Array, Hash]" + ) + ) + expect(got).to eql(want) + end + end +end diff --git a/providers/openfeature-ofrep-provider/spec/spec_helper.rb b/providers/openfeature-ofrep-provider/spec/spec_helper.rb new file mode 100644 index 0000000..7aea11f --- /dev/null +++ b/providers/openfeature-ofrep-provider/spec/spec_helper.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "bundler/setup" +require "rspec" +require "openfeature/ofrep/provider" +require "open_feature/sdk" +require "webmock/rspec" + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + config.filter_run_when_matching :focus + config.example_status_persistence_file_path = "spec/examples.txt" + config.disable_monkey_patching! + config.warnings = true + config.order = :random + Kernel.srand config.seed +end diff --git a/release-please-config.json b/release-please-config.json index a14d836..0c414a6 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -42,6 +42,16 @@ "README.md" ] }, + "providers/openfeature-ofrep-provider": { + "package-name": "openfeature-ofrep-provider", + "version-file": "lib/openfeature/ofrep/provider/version.rb", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "README.md" + ] + }, "providers/openfeature-flagsmith-provider": { "package-name": "openfeature-flagsmith-provider", "version-file": "lib/openfeature/flagsmith/version.rb", From b261a1aa89016deeef25fcd8335e100391d44095 Mon Sep 17 00:00:00 2001 From: Jose Colella Date: Sat, 7 Mar 2026 08:23:58 -0800 Subject: [PATCH 2/6] fix(ofrep): improve thread safety and nil guards in client - Initialize @retry_lock mutex in constructor instead of lazy init - Add nil guards in reason_mapper and error_code_mapper Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jose Colella --- .../lib/openfeature/ofrep/provider/client.rb | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/client.rb b/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/client.rb index 8135e5e..17542b4 100644 --- a/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/client.rb +++ b/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/client.rb @@ -11,6 +11,7 @@ module OFREP class Client def initialize(configuration:) @configuration = configuration + @retry_lock = Mutex.new request_options = {timeout: configuration.timeout} @faraday_connection = Faraday.new( url: configuration.base_url, @@ -64,8 +65,7 @@ def evaluation_request(evaluation_context) end def check_retry_after - lock = (@retry_lock ||= Mutex.new) - lock.synchronize do + @retry_lock.synchronize do return if @retry_after.nil? if Time.now < @retry_after raise OpenFeature::OFREP::RateLimited.new(nil) @@ -116,6 +116,7 @@ def parse_success_response(response) end def reason_mapper(reason_str) + return SDK::Provider::Reason::UNKNOWN if reason_str.nil? reason_str = reason_str.upcase reason_map = { "STATIC" => SDK::Provider::Reason::STATIC, @@ -132,6 +133,7 @@ def reason_mapper(reason_str) end def error_code_mapper(error_code_str) + return SDK::Provider::ErrorCode::GENERAL if error_code_str.nil? error_code_str = error_code_str.upcase error_code_map = { "PROVIDER_NOT_READY" => SDK::Provider::ErrorCode::PROVIDER_NOT_READY, @@ -156,8 +158,7 @@ def parse_retry_later_header(response) else Time.httpdate(retry_after) end - lock = (@retry_lock ||= Mutex.new) - lock.synchronize do + @retry_lock.synchronize do @retry_after = [@retry_after, next_retry_time].compact.max end rescue ArgumentError From 7995c85f8e1b6b109d7ca8a16254f69b2861a942 Mon Sep 17 00:00:00 2001 From: Jose Colella Date: Sat, 7 Mar 2026 08:32:39 -0800 Subject: [PATCH 3/6] fix(ofrep): address review feedback - URL-encode flag_key to prevent path traversal (security) - Extract REASON_MAP and ERROR_CODE_MAP as frozen constants - Remove redundant nil check on evaluation_context in validate_parameters - Remove unnecessary fallback in evaluation_request - Improve RateLimited error message for missing Retry-After header Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jose Colella --- .../lib/openfeature/ofrep/provider.rb | 2 +- .../lib/openfeature/ofrep/provider/client.rb | 53 ++++++++++--------- .../lib/openfeature/ofrep/provider/errors.rb | 3 +- 3 files changed, 30 insertions(+), 28 deletions(-) diff --git a/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider.rb b/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider.rb index 0453121..994a44e 100644 --- a/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider.rb +++ b/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider.rb @@ -94,7 +94,7 @@ def evaluate(flag_key:, default_value:, allowed_classes:, evaluation_context: ni end def validate_parameters(flag_key, evaluation_context) - if evaluation_context.nil? || evaluation_context.targeting_key.nil? || evaluation_context.targeting_key.empty? + if evaluation_context.targeting_key.nil? || evaluation_context.targeting_key.empty? raise InvalidOptionError.new(SDK::Provider::ErrorCode::INVALID_CONTEXT, "invalid evaluation context provided") end diff --git a/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/client.rb b/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/client.rb index 17542b4..9d900b0 100644 --- a/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/client.rb +++ b/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/client.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "cgi" require "json" require "open_feature/sdk" require "faraday/net_http_persistent" @@ -9,6 +10,28 @@ module OpenFeature module OFREP class Client + REASON_MAP = { + "STATIC" => SDK::Provider::Reason::STATIC, + "DEFAULT" => SDK::Provider::Reason::DEFAULT, + "TARGETING_MATCH" => SDK::Provider::Reason::TARGETING_MATCH, + "SPLIT" => SDK::Provider::Reason::SPLIT, + "CACHED" => SDK::Provider::Reason::CACHED, + "DISABLED" => SDK::Provider::Reason::DISABLED, + "UNKNOWN" => SDK::Provider::Reason::UNKNOWN, + "STALE" => SDK::Provider::Reason::STALE, + "ERROR" => SDK::Provider::Reason::ERROR + }.freeze + + ERROR_CODE_MAP = { + "PROVIDER_NOT_READY" => SDK::Provider::ErrorCode::PROVIDER_NOT_READY, + "FLAG_NOT_FOUND" => SDK::Provider::ErrorCode::FLAG_NOT_FOUND, + "PARSE_ERROR" => SDK::Provider::ErrorCode::PARSE_ERROR, + "TYPE_MISMATCH" => SDK::Provider::ErrorCode::TYPE_MISMATCH, + "TARGETING_KEY_MISSING" => SDK::Provider::ErrorCode::TARGETING_KEY_MISSING, + "INVALID_CONTEXT" => SDK::Provider::ErrorCode::INVALID_CONTEXT, + "GENERAL" => SDK::Provider::ErrorCode::GENERAL + }.freeze + def initialize(configuration:) @configuration = configuration @retry_lock = Mutex.new @@ -28,7 +51,7 @@ def evaluate(flag_key:, evaluation_context:) check_retry_after request = evaluation_request(evaluation_context) - response = @faraday_connection.post("/ofrep/v1/evaluate/flags/#{flag_key}") do |req| + response = @faraday_connection.post("/ofrep/v1/evaluate/flags/#{CGI.escape(flag_key)}") do |req| req.body = request.to_json end @@ -56,7 +79,7 @@ def build_headers end def evaluation_request(evaluation_context) - ctx = evaluation_context || OpenFeature::SDK::EvaluationContext.new + ctx = evaluation_context fields = ctx.fields.dup fields["targetingKey"] = ctx.targeting_key fields.delete("targeting_key") @@ -117,34 +140,12 @@ def parse_success_response(response) def reason_mapper(reason_str) return SDK::Provider::Reason::UNKNOWN if reason_str.nil? - reason_str = reason_str.upcase - reason_map = { - "STATIC" => SDK::Provider::Reason::STATIC, - "DEFAULT" => SDK::Provider::Reason::DEFAULT, - "TARGETING_MATCH" => SDK::Provider::Reason::TARGETING_MATCH, - "SPLIT" => SDK::Provider::Reason::SPLIT, - "CACHED" => SDK::Provider::Reason::CACHED, - "DISABLED" => SDK::Provider::Reason::DISABLED, - "UNKNOWN" => SDK::Provider::Reason::UNKNOWN, - "STALE" => SDK::Provider::Reason::STALE, - "ERROR" => SDK::Provider::Reason::ERROR - } - reason_map[reason_str] || SDK::Provider::Reason::UNKNOWN + REASON_MAP[reason_str.upcase] || SDK::Provider::Reason::UNKNOWN end def error_code_mapper(error_code_str) return SDK::Provider::ErrorCode::GENERAL if error_code_str.nil? - error_code_str = error_code_str.upcase - error_code_map = { - "PROVIDER_NOT_READY" => SDK::Provider::ErrorCode::PROVIDER_NOT_READY, - "FLAG_NOT_FOUND" => SDK::Provider::ErrorCode::FLAG_NOT_FOUND, - "PARSE_ERROR" => SDK::Provider::ErrorCode::PARSE_ERROR, - "TYPE_MISMATCH" => SDK::Provider::ErrorCode::TYPE_MISMATCH, - "TARGETING_KEY_MISSING" => SDK::Provider::ErrorCode::TARGETING_KEY_MISSING, - "INVALID_CONTEXT" => SDK::Provider::ErrorCode::INVALID_CONTEXT, - "GENERAL" => SDK::Provider::ErrorCode::GENERAL - } - error_code_map[error_code_str] || SDK::Provider::ErrorCode::GENERAL + ERROR_CODE_MAP[error_code_str.upcase] || SDK::Provider::ErrorCode::GENERAL end def parse_retry_later_header(response) diff --git a/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/errors.rb b/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/errors.rb index e93dfdd..22e2f58 100644 --- a/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/errors.rb +++ b/providers/openfeature-ofrep-provider/lib/openfeature/ofrep/provider/errors.rb @@ -66,7 +66,8 @@ class RateLimited < StandardError attr_reader :response, :error_code, :error_message def initialize(response) - error_message = response.nil? ? "Rate limited" : "Rate limited: " + response["Retry-After"].to_s + error_message = "Rate limited" + error_message += ": #{response["Retry-After"]}" if response&.[]("Retry-After") @response = response @error_code = SDK::Provider::ErrorCode::GENERAL @error_message = error_message From aebf6ca55d90c2f939cdb4adf3e1ac01aa4176d5 Mon Sep 17 00:00:00 2001 From: Jose Colella Date: Sat, 7 Mar 2026 08:34:16 -0800 Subject: [PATCH 4/6] chore(ofrep): remove Gemfile.lock Library gems should not commit Gemfile.lock. This also fixes CI Bundler version mismatch (lockfile had 4.0.6, CI uses 2.x). Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jose Colella --- .../openfeature-ofrep-provider/Gemfile.lock | 162 ------------------ 1 file changed, 162 deletions(-) delete mode 100644 providers/openfeature-ofrep-provider/Gemfile.lock diff --git a/providers/openfeature-ofrep-provider/Gemfile.lock b/providers/openfeature-ofrep-provider/Gemfile.lock deleted file mode 100644 index 4796952..0000000 --- a/providers/openfeature-ofrep-provider/Gemfile.lock +++ /dev/null @@ -1,162 +0,0 @@ -PATH - remote: . - specs: - openfeature-ofrep-provider (0.1.0) - faraday-net_http_persistent (~> 2.3) - openfeature-sdk (~> 0.3.1) - -GEM - remote: https://rubygems.org/ - specs: - addressable (2.8.9) - public_suffix (>= 2.0.2, < 8.0) - ast (2.4.3) - bigdecimal (4.0.1) - connection_pool (3.0.2) - crack (1.0.1) - bigdecimal - rexml - diff-lcs (1.6.2) - faraday (2.14.1) - faraday-net_http (>= 2.0, < 3.5) - json - logger - faraday-net_http (3.4.2) - net-http (~> 0.5) - faraday-net_http_persistent (2.3.1) - faraday (~> 2.5) - net-http-persistent (>= 4.0.4, < 5) - hashdiff (1.2.1) - json (2.19.0) - language_server-protocol (3.17.0.5) - lint_roller (1.1.0) - logger (1.7.0) - net-http (0.9.1) - uri (>= 0.11.1) - net-http-persistent (4.0.8) - connection_pool (>= 2.2.4, < 4) - openfeature-sdk (0.3.1) - parallel (1.27.0) - parser (3.3.10.2) - ast (~> 2.4.1) - racc - prism (1.9.0) - public_suffix (7.0.5) - racc (1.8.1) - rainbow (3.1.1) - rake (13.3.1) - regexp_parser (2.11.3) - rexml (3.4.4) - rspec (3.12.0) - rspec-core (~> 3.12.0) - rspec-expectations (~> 3.12.0) - rspec-mocks (~> 3.12.0) - rspec-core (3.12.3) - rspec-support (~> 3.12.0) - rspec-expectations (3.12.4) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-mocks (3.12.7) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-support (3.12.2) - rubocop (1.84.2) - json (~> 2.3) - language_server-protocol (~> 3.17.0.2) - lint_roller (~> 1.1.0) - parallel (~> 1.10) - parser (>= 3.3.0.2) - rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.49.0, < 2.0) - ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.49.0) - parser (>= 3.3.7.2) - prism (~> 1.7) - rubocop-performance (1.26.1) - lint_roller (~> 1.1) - rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.47.1, < 2.0) - ruby-progressbar (1.13.0) - standard (1.54.0) - language_server-protocol (~> 3.17.0.2) - lint_roller (~> 1.0) - rubocop (~> 1.84.0) - standard-custom (~> 1.0.0) - standard-performance (~> 1.8) - standard-custom (1.0.2) - lint_roller (~> 1.0) - rubocop (~> 1.50) - standard-performance (1.9.0) - lint_roller (~> 1.1) - rubocop-performance (~> 1.26.0) - unicode-display_width (3.2.0) - unicode-emoji (~> 4.1) - unicode-emoji (4.2.0) - uri (1.1.1) - webmock (3.26.1) - addressable (>= 2.8.0) - crack (>= 0.3.2) - hashdiff (>= 0.4.0, < 2.0.0) - -PLATFORMS - arm64-darwin-24 - ruby - -DEPENDENCIES - openfeature-ofrep-provider! - rake (~> 13.0) - rspec (~> 3.12.0) - rubocop - standard (>= 1.35.1) - standard-performance - webmock - -CHECKSUMS - addressable (2.8.9) sha256=cc154fcbe689711808a43601dee7b980238ce54368d23e127421753e46895485 - ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 - bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 - connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a - crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e - diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 - faraday (2.14.1) sha256=a43cceedc1e39d188f4d2cdd360a8aaa6a11da0c407052e426ba8d3fb42ef61c - faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c - faraday-net_http_persistent (2.3.1) sha256=23ffba37d6a27807a10f033d01918ec958aa73fa6ff0fccfbcd5ce2d2e68fca3 - hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 - json (2.19.0) sha256=bc5202f083618b3af7aba3184146ec9d820f8f6de261838b577173475e499d9a - language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc - lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 - logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 - net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 - net-http-persistent (4.0.8) sha256=ef3de8319d691537b329053fae3a33195f8b070bbbfae8bf1a58c796081960e6 - openfeature-ofrep-provider (0.1.0) - openfeature-sdk (0.3.1) sha256=17a930f52c66ee76a5d20f5bb68cfadbb8d86a4db5b1f99caab8c60ab577f9e5 - parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 - parser (3.3.10.2) sha256=6f60c84aa4bdcedb6d1a2434b738fe8a8136807b6adc8f7f53b97da9bc4e9357 - prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 - public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 - racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f - rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a - rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c - regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 - rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 - rspec (3.12.0) sha256=ccc41799a43509dc0be84070e3f0410ac95cbd480ae7b6c245543eb64162399c - rspec-core (3.12.3) sha256=782189dea5ac28971d2a613ff7bf28b4c4e326ec3f0503c773307671269b6f0d - rspec-expectations (3.12.4) sha256=846ff8968551a775c4c949825d581f394a517bdf78b68feac471282a1b03e75c - rspec-mocks (3.12.7) sha256=b2f10a7879781de9448f28e5dc399277d4eac14d577b450e4c04b7897fe4c6a8 - rspec-support (3.12.2) sha256=de719abdc9d03842c96052be4a2dab8d7fd9314d0b2488b0f755008d071b761d - rubocop (1.84.2) sha256=5692cea54168f3dc8cb79a6fe95c5424b7ea893c707ad7a4307b0585e88dbf5f - rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd - rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 - ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 - standard (1.54.0) sha256=7a4b08f83d9893083c8f03bc486f0feeb6a84d48233b40829c03ef4767ea0100 - standard-custom (1.0.2) sha256=424adc84179a074f1a2a309bb9cf7cd6bfdb2b6541f20c6bf9436c0ba22a652b - standard-performance (1.9.0) sha256=49483d31be448292951d80e5e67cdcb576c2502103c7b40aec6f1b6e9c88e3f2 - unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 - unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f - uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 - webmock (3.26.1) sha256=4f696fb57c90a827c20aadb2d4f9058bbff10f7f043bd0d4c3f58791143b1cd7 - -BUNDLED WITH - 4.0.6 From a95266dba69c86980a48489bbdfa27d108b37697 Mon Sep 17 00:00:00 2001 From: Jose Miguel Colella Date: Sat, 7 Mar 2026 08:48:19 -0800 Subject: [PATCH 5/6] test: add shared OpenFeature provider conformance tests (#81) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Creates shared RSpec examples in `shared_config/conformance/provider_shared_examples.rb` to verify provider interface conformance - Tests metadata presence, resolution method signatures (`fetch_boolean_value`, `fetch_string_value`, `fetch_number_value`, `fetch_object_value`) - Separate optional shared example for providers with `fetch_integer_value`/`fetch_float_value` support - Integrates conformance tests into all 6 provider specs (flagd, flagsmith, flipt, go-feature-flag, meta_provider, ofrep) Closes #61 **Note:** This PR is based on #80 (OFREP provider). Merge #80 first, then rebase this onto main. - [x] OFREP provider: 44 examples, 0 failures (37 existing + 7 conformance) - [x] Go Feature Flag provider: 77 examples, 0 failures - [ ] Remaining providers to be verified in CI *🤖 Jose's AI agent* Signed-off-by: Jose Colella Co-authored-by: Claude Opus 4.6 (1M context) --- .../spec/openfeature/flagd/provider_spec.rb | 8 ++ .../openfeature/flagsmith/provider_spec.rb | 4 + .../spec/openfeature/flipt/provider_spec.rb | 4 + .../gofeatureflag/provider_spec.rb | 7 ++ .../spec/openfeature/meta_provider_spec.rb | 7 ++ .../spec/openfeature/ofrep/provider_spec.rb | 8 ++ .../conformance/provider_shared_examples.rb | 73 +++++++++++++++++++ 7 files changed, 111 insertions(+) create mode 100644 shared_config/conformance/provider_shared_examples.rb diff --git a/providers/openfeature-flagd-provider/spec/openfeature/flagd/provider_spec.rb b/providers/openfeature-flagd-provider/spec/openfeature/flagd/provider_spec.rb index f17e878..4bd1c97 100644 --- a/providers/openfeature-flagd-provider/spec/openfeature/flagd/provider_spec.rb +++ b/providers/openfeature-flagd-provider/spec/openfeature/flagd/provider_spec.rb @@ -3,6 +3,7 @@ require "spec_helper" require "open_feature/sdk" require "tempfile" +require_relative "../../../../../shared_config/conformance/provider_shared_examples" # https://openfeature.dev/docs/specification/sections/providers @@ -15,6 +16,13 @@ subject(:flagd_client) { described_class.build_client } + describe "conformance" do + let(:provider) { described_class.build_client } + + it_behaves_like "an OpenFeature provider" + it_behaves_like "an OpenFeature provider with integer and float support" + end + context "#configure" do context "when defining host, port and tls options of gRPC service it wishes to access with configure method" do subject(:explicit_configuration) do diff --git a/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb index 749d939..7261bad 100644 --- a/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb +++ b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb @@ -1,5 +1,6 @@ require "spec_helper" require "openfeature/flagsmith/provider" +require_relative "../../../../../shared_config/conformance/provider_shared_examples" RSpec.describe OpenFeature::Flagsmith::Provider do let(:options) do @@ -21,6 +22,9 @@ def mock_flag(feature_name:, enabled:, value:) allow(::Flagsmith::Client).to receive(:new).and_return(mock_flagsmith_client) end + it_behaves_like "an OpenFeature provider" + it_behaves_like "an OpenFeature provider with integer and float support" + describe "#initialize" do it "should create provider with options" do expect(provider.options).to eq(options) diff --git a/providers/openfeature-flipt-provider/spec/openfeature/flipt/provider_spec.rb b/providers/openfeature-flipt-provider/spec/openfeature/flipt/provider_spec.rb index 4ca3234..d3f28e7 100644 --- a/providers/openfeature-flipt-provider/spec/openfeature/flipt/provider_spec.rb +++ b/providers/openfeature-flipt-provider/spec/openfeature/flipt/provider_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "spec_helper" +require_relative "../../../../../shared_config/conformance/provider_shared_examples" RSpec.describe OpenFeature::Flipt::Provider do let(:provider) { described_class.new(namespace: "test-namespace") } @@ -11,6 +12,9 @@ allow(::Flipt::EvaluationClient).to receive(:new).with("test-namespace", {}).and_return(client_stub) end + it_behaves_like "an OpenFeature provider" + it_behaves_like "an OpenFeature provider with integer and float support" + context "2.1 - Feature Provider Interface" do describe "#metadata" do it "returns a name field which identifies the provider implementation" do diff --git a/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/provider_spec.rb b/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/provider_spec.rb index adc6060..671ae9f 100644 --- a/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/provider_spec.rb +++ b/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/provider_spec.rb @@ -1,4 +1,5 @@ require "spec_helper" +require_relative "../../../../../shared_config/conformance/provider_shared_examples" RSpec.describe OpenFeature::GoFeatureFlag::Provider do subject(:goff_provider) do @@ -6,6 +7,12 @@ described_class.new(options: options) end + describe "conformance" do + let(:provider) { goff_provider } + + it_behaves_like "an OpenFeature provider" + end + context "#metadata" do it "metadata name is defined" do expect(goff_provider).to respond_to(:metadata) diff --git a/providers/openfeature-meta_provider/spec/openfeature/meta_provider_spec.rb b/providers/openfeature-meta_provider/spec/openfeature/meta_provider_spec.rb index 17a42bb..ffb19a6 100644 --- a/providers/openfeature-meta_provider/spec/openfeature/meta_provider_spec.rb +++ b/providers/openfeature-meta_provider/spec/openfeature/meta_provider_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "spec_helper" +require_relative "../../../../shared_config/conformance/provider_shared_examples" RSpec.shared_examples "meta resolution" do |type, default_value, first_matched_value, second_matched_value| context "when strategy is first_match" do @@ -84,6 +85,12 @@ ) end + describe "conformance" do + let(:provider) { meta_provider } + + it_behaves_like "an OpenFeature provider" + end + describe "#metadata" do it "combines all metadata names" do expect(meta_provider.metadata.name).to eq("MetaProvider: In-memory Provider, In-memory Provider") diff --git a/providers/openfeature-ofrep-provider/spec/openfeature/ofrep/provider_spec.rb b/providers/openfeature-ofrep-provider/spec/openfeature/ofrep/provider_spec.rb index 65deef3..d19736b 100644 --- a/providers/openfeature-ofrep-provider/spec/openfeature/ofrep/provider_spec.rb +++ b/providers/openfeature-ofrep-provider/spec/openfeature/ofrep/provider_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "spec_helper" +require_relative "../../../../../shared_config/conformance/provider_shared_examples" RSpec.describe OpenFeature::OFREP::Provider do subject(:ofrep_provider) do @@ -8,6 +9,13 @@ described_class.new(configuration: configuration) end + describe "conformance" do + let(:provider) { ofrep_provider } + + it_behaves_like "an OpenFeature provider" + it_behaves_like "an OpenFeature provider with integer and float support" + end + context "#metadata" do it "metadata name is defined" do expect(ofrep_provider).to respond_to(:metadata) diff --git a/shared_config/conformance/provider_shared_examples.rb b/shared_config/conformance/provider_shared_examples.rb new file mode 100644 index 0000000..0e9ae2e --- /dev/null +++ b/shared_config/conformance/provider_shared_examples.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +# Shared examples for OpenFeature provider conformance testing. +# +# Usage: +# require_relative "path/to/shared_config/conformance/provider_shared_examples.rb" +# +# RSpec.describe MyProvider do +# let(:provider) { MyProvider.new(...) } +# +# it_behaves_like "an OpenFeature provider" +# end + +RSpec.shared_examples "an OpenFeature provider" do + describe "provider interface conformance" do + it "exposes metadata with a non-empty name" do + expect(provider).to respond_to(:metadata) + expect(provider.metadata).to respond_to(:name) + expect(provider.metadata.name).to be_a(String) + expect(provider.metadata.name).not_to be_empty + end + + it "responds to fetch_boolean_value with keyword arguments" do + expect(provider).to respond_to(:fetch_boolean_value) + + method = provider.method(:fetch_boolean_value) + param_names = method.parameters.map(&:last) + expect(param_names).to include(:flag_key, :default_value) + end + + it "responds to fetch_string_value with keyword arguments" do + expect(provider).to respond_to(:fetch_string_value) + + method = provider.method(:fetch_string_value) + param_names = method.parameters.map(&:last) + expect(param_names).to include(:flag_key, :default_value) + end + + it "responds to fetch_number_value with keyword arguments" do + expect(provider).to respond_to(:fetch_number_value) + + method = provider.method(:fetch_number_value) + param_names = method.parameters.map(&:last) + expect(param_names).to include(:flag_key, :default_value) + end + + it "responds to fetch_object_value with keyword arguments" do + expect(provider).to respond_to(:fetch_object_value) + + method = provider.method(:fetch_object_value) + param_names = method.parameters.map(&:last) + expect(param_names).to include(:flag_key, :default_value) + end + end +end + +RSpec.shared_examples "an OpenFeature provider with integer and float support" do + it "responds to fetch_integer_value with keyword arguments" do + expect(provider).to respond_to(:fetch_integer_value) + + method = provider.method(:fetch_integer_value) + param_names = method.parameters.map(&:last) + expect(param_names).to include(:flag_key, :default_value) + end + + it "responds to fetch_float_value with keyword arguments" do + expect(provider).to respond_to(:fetch_float_value) + + method = provider.method(:fetch_float_value) + param_names = method.parameters.map(&:last) + expect(param_names).to include(:flag_key, :default_value) + end +end From 96f2eea884048f8950a2fd0ccd12afa8720131d1 Mon Sep 17 00:00:00 2001 From: Jose Colella Date: Sun, 8 Mar 2026 22:08:03 -0700 Subject: [PATCH 6/6] fix: resolve flaky rate-limit date test in ofrep provider Change Retry-After date offset from 1 second to 60 seconds to prevent sub-second precision truncation in httpdate from causing the retry-after time to be in the past when the assertion runs. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jose Colella --- providers/openfeature-flipt-provider/Gemfile.lock | 2 +- .../openfeature-flipt-provider/lib/openfeature/flipt/version.rb | 2 +- .../spec/openfeature/ofrep/provider/client_spec.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/providers/openfeature-flipt-provider/Gemfile.lock b/providers/openfeature-flipt-provider/Gemfile.lock index 3d0ef6e..9a4251e 100644 --- a/providers/openfeature-flipt-provider/Gemfile.lock +++ b/providers/openfeature-flipt-provider/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - openfeature-flipt-provider (0.0.2) + openfeature-flipt-provider (0.0.3) ffi (~> 1.17) flipt_client (~> 0.10.0) openfeature-sdk (~> 0.4.0) diff --git a/providers/openfeature-flipt-provider/lib/openfeature/flipt/version.rb b/providers/openfeature-flipt-provider/lib/openfeature/flipt/version.rb index 6644632..0e1bdc1 100644 --- a/providers/openfeature-flipt-provider/lib/openfeature/flipt/version.rb +++ b/providers/openfeature-flipt-provider/lib/openfeature/flipt/version.rb @@ -2,6 +2,6 @@ module OpenFeature module Flipt - VERSION = "0.0.2" + VERSION = "0.0.3" end end diff --git a/providers/openfeature-ofrep-provider/spec/openfeature/ofrep/provider/client_spec.rb b/providers/openfeature-ofrep-provider/spec/openfeature/ofrep/provider/client_spec.rb index 0a94314..649f3b5 100644 --- a/providers/openfeature-ofrep-provider/spec/openfeature/ofrep/provider/client_spec.rb +++ b/providers/openfeature-ofrep-provider/spec/openfeature/ofrep/provider/client_spec.rb @@ -173,7 +173,7 @@ it "blocks subsequent calls when rate limited with Retry-After header (date)" do stub_request(:post, "http://localhost:8080/ofrep/v1/evaluate/flags/my_flag") - .to_return(status: 429, headers: {"Retry-After" => (Time.now + 1).httpdate}) + .to_return(status: 429, headers: {"Retry-After" => (Time.now + 60).httpdate}) expect { client.evaluate(flag_key: "my_flag", evaluation_context: default_evaluation_context)