Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/main/java/dev/openfeature/sdk/ProviderEvaluation.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

/**
* Contains information about how the a flag was evaluated, including the resolved value.
*
* @param <T> the type of the flag being evaluated.
*/
@Data
@Builder
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
public class ProviderEvaluation<T> implements BaseEvaluation<T> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import dev.openfeature.sdk.FeatureProvider;
import dev.openfeature.sdk.ProviderEvaluation;
import dev.openfeature.sdk.exceptions.FlagNotFoundError;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import lombok.NoArgsConstructor;
Expand All @@ -20,7 +22,8 @@
* <li>Skip providers that indicate they had no value due to {@code FLAG_NOT_FOUND}.</li>
* <li>On any other error code, return that error result.</li>
* <li>If a provider throws {@link FlagNotFoundError}, it is treated like {@code FLAG_NOT_FOUND}.</li>
* <li>If all providers report {@code FLAG_NOT_FOUND}, return a {@code FLAG_NOT_FOUND} error.</li>
* <li>If all providers report {@code FLAG_NOT_FOUND}, return a {@code FLAG_NOT_FOUND} error
* with per-provider error details.</li>
* </ul>
* As soon as a non-{@code FLAG_NOT_FOUND} result is returned by a provider (success or other error),
* the rest of the operation short-circuits and does not call the remaining providers.
Expand All @@ -36,7 +39,11 @@ public <T> ProviderEvaluation<T> evaluate(
T defaultValue,
EvaluationContext ctx,
Function<FeatureProvider, ProviderEvaluation<T>> providerFunction) {
for (FeatureProvider provider : providers.values()) {
List<ProviderError> collectedErrors = new ArrayList<>();

for (Map.Entry<String, FeatureProvider> entry : providers.entrySet()) {
String providerName = entry.getKey();
FeatureProvider provider = entry.getValue();
try {
ProviderEvaluation<T> res = providerFunction.apply(provider);
ErrorCode errorCode = res.getErrorCode();
Expand All @@ -45,19 +52,22 @@ public <T> ProviderEvaluation<T> evaluate(
return res;
}
if (!FLAG_NOT_FOUND.equals(errorCode)) {
// Any non-FLAG_NOT_FOUND error bubbles up
// Any non-FLAG_NOT_FOUND error bubbles up immediately
return res;
}
// else FLAG_NOT_FOUND: skip to next provider
} catch (FlagNotFoundError ignored) {
// do not log in hot path, just skip
// FLAG_NOT_FOUND: record and skip to next provider
collectedErrors.add(ProviderError.fromResult(providerName, FLAG_NOT_FOUND, res.getErrorMessage()));
} catch (FlagNotFoundError e) {
// Treat thrown FlagNotFoundError like a FLAG_NOT_FOUND result
collectedErrors.add(ProviderError.fromException(providerName, e));
}
}

// All providers either threw or returned FLAG_NOT_FOUND
return ProviderEvaluation.<T>builder()
.errorMessage("Flag not found in any provider")
return MultiProviderEvaluation.<T>builder()
.errorMessage(ProviderError.buildAggregateMessage("Flag not found in any provider", collectedErrors))
.errorCode(FLAG_NOT_FOUND)
.providerErrors(collectedErrors)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import dev.openfeature.sdk.EvaluationContext;
import dev.openfeature.sdk.FeatureProvider;
import dev.openfeature.sdk.ProviderEvaluation;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import lombok.NoArgsConstructor;
Expand All @@ -12,9 +14,10 @@
/**
* First Successful Strategy.
*
* <p>Similar to First Match, except that errors from evaluated providers do not halt execution.
* <p>Similar to "First Match", except that errors from evaluated providers do not halt execution.
* Instead, it returns the first successful result from a provider. If no provider successfully
* responds, it returns a {@code GENERAL} error result.
* responds, it returns a {@code GENERAL} error result that includes per-provider error details
* describing why each provider failed.
*/
@Slf4j
@NoArgsConstructor
Expand All @@ -27,22 +30,30 @@ public <T> ProviderEvaluation<T> evaluate(
T defaultValue,
EvaluationContext ctx,
Function<FeatureProvider, ProviderEvaluation<T>> providerFunction) {
for (FeatureProvider provider : providers.values()) {
List<ProviderError> collectedErrors = new ArrayList<>();

for (Map.Entry<String, FeatureProvider> entry : providers.entrySet()) {
String providerName = entry.getKey();
FeatureProvider provider = entry.getValue();
try {
ProviderEvaluation<T> res = providerFunction.apply(provider);
if (res.getErrorCode() == null) {
// First successful result (no error code)
return res;
}
} catch (Exception ignored) {
// swallow and continue; errors from individual providers
// are not fatal for this strategy
// Record error-coded result
collectedErrors.add(ProviderError.fromResult(providerName, res.getErrorCode(), res.getErrorMessage()));
} catch (Exception e) {
// Record thrown exception
collectedErrors.add(ProviderError.fromException(providerName, e));
}
}

return ProviderEvaluation.<T>builder()
.errorMessage("No provider successfully responded")
return MultiProviderEvaluation.<T>builder()
.errorMessage(
ProviderError.buildAggregateMessage("No provider successfully responded", collectedErrors))
.errorCode(ErrorCode.GENERAL)
.providerErrors(collectedErrors)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package dev.openfeature.sdk.multiprovider;

import dev.openfeature.sdk.ProviderEvaluation;
import java.util.Collections;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
import lombok.experimental.SuperBuilder;

/**
* A {@link ProviderEvaluation} subtype returned by multi-provider strategies that carries
* per-provider error details.
*
* <p>This type can represent both successful and failed evaluations. When a strategy exhausts
* all providers without a successful result, the per-provider errors describe why each provider
* failed. Custom strategies may also use this type for successful results to surface information
* about providers that were skipped or failed before the successful one.
*
* <p>Usage:
* <pre>{@code
* ProviderEvaluation<String> result = strategy.evaluate(...);
* if (result instanceof MultiProviderEvaluation<String> multiResult) {
* for (ProviderError error : multiResult.getProviderErrors()) {
* log.warn("Provider {} failed: {} - {}",
* error.getProviderName(), error.getErrorCode(), error.getErrorMessage());
* }
* }
* }</pre>
*
* @param <T> the type of the flag being evaluated
*/
@Getter
@SuperBuilder
public class MultiProviderEvaluation<T> extends ProviderEvaluation<T> {

/**
* Per-provider error details.
*
* <p>Each entry describes why a specific provider failed during multi-provider evaluation.
* Defaults to an empty list when not set.
*/
@Builder.Default
private List<ProviderError> providerErrors = Collections.emptyList();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package dev.openfeature.sdk.multiprovider;

import dev.openfeature.sdk.ErrorCode;
import dev.openfeature.sdk.exceptions.OpenFeatureError;
import java.util.List;
import java.util.stream.Collectors;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;

/**
* Represents an error from a single provider during multi-provider evaluation.
*
* <p>Captures the provider name, error code, error message, and optionally the original exception
* that occurred during flag evaluation. This allows callers to inspect per-provider error details
* when a multi-provider strategy exhausts all providers without a successful result.
*/
@Data
@Builder
@AllArgsConstructor
public class ProviderError {
Comment thread
suthar26 marked this conversation as resolved.
private String providerName;
private ErrorCode errorCode;
private String errorMessage;
private Exception exception;

/**
* Create a ProviderError from an error-coded {@code ProviderEvaluation} result.
*
* @param providerName the name of the provider that returned the error
* @param errorCode the error code from the evaluation result
* @param errorMessage the error message from the evaluation result (may be {@code null})
* @return a new ProviderError
*/
public static ProviderError fromResult(String providerName, ErrorCode errorCode, String errorMessage) {
return new ProviderError(providerName, errorCode, errorMessage, null);
}

/**
* Create a ProviderError from a thrown exception.
*
* @param providerName the name of the provider that threw the exception
* @param exception the exception that was thrown
* @return a new ProviderError
*/
public static ProviderError fromException(String providerName, Exception exception) {
ErrorCode code = ErrorCode.GENERAL;
if (exception instanceof OpenFeatureError) {
code = ((OpenFeatureError) exception).getErrorCode();
}
return new ProviderError(providerName, code, exception.getMessage(), exception);
}

/**
* Build an aggregate error message from a list of provider errors.
*
* @param baseMessage the base message to use (e.g. "No provider successfully responded")
* @param errors the list of per-provider errors
* @return an aggregate message including per-provider details
*/
public static String buildAggregateMessage(String baseMessage, List<ProviderError> errors) {
String details = errors.stream().map(ProviderError::toString).collect(Collectors.joining(", "));
return baseMessage + ". Provider errors: [" + details + "]";
}

@Override
public String toString() {
return providerName + ": " + errorCode + " (" + (errorMessage != null ? errorMessage : "unknown") + ")";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
* <li>Order or select providers</li>
* <li>Handle {@code FLAG_NOT_FOUND} results</li>
* <li>Handle errors and exceptions from providers</li>
* <li>Collect per-provider error details when no provider returns a successful result.
* Implementations should return a {@link MultiProviderEvaluation} populated with
* a {@link ProviderError} for each failed provider, so that callers can inspect individual
* failure reasons.</li>
* </ul>
*/
public interface Strategy {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,4 +211,17 @@ protected void setupProviderSuccess(FeatureProvider provider, String value) {
when(provider.getStringEvaluation(BaseStrategyTest.FLAG_KEY, DEFAULT_STRING, null))
.thenReturn(result);
}

protected void setupProviderErrorWithMessage(FeatureProvider provider, ErrorCode errorCode, String errorMessage) {
ProviderEvaluation<String> result = mock(ProviderEvaluation.class);
when(result.getErrorCode()).thenReturn(errorCode);
when(result.getErrorMessage()).thenReturn(errorMessage);
when(provider.getStringEvaluation(BaseStrategyTest.FLAG_KEY, DEFAULT_STRING, null))
.thenReturn(result);
}

protected void setupProviderException(FeatureProvider provider, RuntimeException exception) {
when(provider.getStringEvaluation(BaseStrategyTest.FLAG_KEY, DEFAULT_STRING, null))
.thenThrow(exception);
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package dev.openfeature.sdk.multiprovider;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import dev.openfeature.sdk.ErrorCode;
import dev.openfeature.sdk.ProviderEvaluation;
import dev.openfeature.sdk.exceptions.FlagNotFoundError;
import java.util.List;
import org.junit.jupiter.api.Test;

class FirstMatchStrategyTest extends BaseStrategyTest {
Expand Down Expand Up @@ -59,19 +63,30 @@ void shouldReturnSuccessWhenFirstProviderSucceeds() {
}

@Test
void shouldThrowFlagNotFoundWhenAllProvidersReturnFlagNotFound() {
void shouldReturnMultiProviderEvaluationWhenAllProvidersReturnFlagNotFound() {
setupProviderFlagNotFound(mockProvider1);
setupProviderFlagNotFound(mockProvider2);
setupProviderFlagNotFound(mockProvider3);
ProviderEvaluation<String> providerEvaluation = strategy.evaluate(
ProviderEvaluation<String> result = strategy.evaluate(
orderedProviders,
FLAG_KEY,
DEFAULT_STRING,
null,
p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null));

assertEquals(ErrorCode.FLAG_NOT_FOUND, providerEvaluation.getErrorCode());
assertEquals("Flag not found in any provider", providerEvaluation.getErrorMessage());
assertEquals(ErrorCode.FLAG_NOT_FOUND, result.getErrorCode());
assertTrue(result.getErrorMessage().contains("Flag not found in any provider"));

MultiProviderEvaluation<String> multiResult = assertInstanceOf(MultiProviderEvaluation.class, result);
List<ProviderError> errors = multiResult.getProviderErrors();
assertNotNull(errors);
assertEquals(3, errors.size());
assertEquals("provider1", errors.get(0).getProviderName());
assertEquals(ErrorCode.FLAG_NOT_FOUND, errors.get(0).getErrorCode());
assertEquals("provider2", errors.get(1).getProviderName());
assertEquals(ErrorCode.FLAG_NOT_FOUND, errors.get(1).getErrorCode());
assertEquals("provider3", errors.get(2).getProviderName());
assertEquals(ErrorCode.FLAG_NOT_FOUND, errors.get(2).getErrorCode());
}

@Test
Expand All @@ -88,4 +103,53 @@ void shouldSkipMultipleFlagNotFoundAndReturnFirstOtherError() {
assertNotNull(result);
assertEquals(ErrorCode.PARSE_ERROR, result.getErrorCode());
}

@Test
void shouldCaptureThrownFlagNotFoundErrorsAsProviderErrors() {
setupProviderException(mockProvider1, new FlagNotFoundError("not in provider1"));
setupProviderException(mockProvider2, new FlagNotFoundError("not in provider2"));
setupProviderException(mockProvider3, new FlagNotFoundError("not in provider3"));

ProviderEvaluation<String> result = strategy.evaluate(
orderedProviders,
FLAG_KEY,
DEFAULT_STRING,
null,
p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null));

assertEquals(ErrorCode.FLAG_NOT_FOUND, result.getErrorCode());

MultiProviderEvaluation<String> multiResult = assertInstanceOf(MultiProviderEvaluation.class, result);
List<ProviderError> errors = multiResult.getProviderErrors();
assertNotNull(errors);
assertEquals(3, errors.size());

assertEquals("provider1", errors.get(0).getProviderName());
assertEquals(ErrorCode.FLAG_NOT_FOUND, errors.get(0).getErrorCode());
assertEquals("not in provider1", errors.get(0).getErrorMessage());
assertNotNull(errors.get(0).getException());

assertEquals("provider2", errors.get(1).getProviderName());
assertEquals("provider3", errors.get(2).getProviderName());
Comment thread
toddbaert marked this conversation as resolved.
}

@Test
void shouldIncludeProviderNamesInAggregateErrorMessage() {
setupProviderFlagNotFound(mockProvider1);
setupProviderFlagNotFound(mockProvider2);
setupProviderFlagNotFound(mockProvider3);

ProviderEvaluation<String> result = strategy.evaluate(
orderedProviders,
FLAG_KEY,
DEFAULT_STRING,
null,
p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null));

String message = result.getErrorMessage();
assertTrue(message.contains("provider1"));
assertTrue(message.contains("provider2"));
assertTrue(message.contains("provider3"));
assertTrue(message.contains("FLAG_NOT_FOUND"));
}
}
Loading
Loading