Skip to content

Commit f938b26

Browse files
suthar26toddbaert
andauthored
fix: collect and propagate per-provider errors in multi-provider strategies (#1901)
* fix: collect and propagate per-provider errors in multi-provider strategies Signed-off-by: Parth Suthar <parth.suthar@dynatrace.com> * update to refactor buildAggregateMessage in ProviderError Signed-off-by: Parth Suthar <parth.suthar@dynatrace.com> * update to use SuperBuilder for MultiProviderEvaluation Signed-off-by: Parth Suthar <parth.suthar@dynatrace.com> --------- Signed-off-by: Parth Suthar <parth.suthar@dynatrace.com> Co-authored-by: Todd Baert <todd.baert@dynatrace.com>
1 parent e8a99d8 commit f938b26

10 files changed

Lines changed: 377 additions & 36 deletions

File tree

src/main/java/dev/openfeature/sdk/ProviderEvaluation.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
import lombok.Builder;
55
import lombok.Data;
66
import lombok.NoArgsConstructor;
7+
import lombok.experimental.SuperBuilder;
78

89
/**
910
* Contains information about how the a flag was evaluated, including the resolved value.
1011
*
1112
* @param <T> the type of the flag being evaluated.
1213
*/
1314
@Data
14-
@Builder
15+
@SuperBuilder
1516
@NoArgsConstructor
1617
@AllArgsConstructor
1718
public class ProviderEvaluation<T> implements BaseEvaluation<T> {

src/main/java/dev/openfeature/sdk/multiprovider/FirstMatchStrategy.java

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import dev.openfeature.sdk.FeatureProvider;
88
import dev.openfeature.sdk.ProviderEvaluation;
99
import dev.openfeature.sdk.exceptions.FlagNotFoundError;
10+
import java.util.ArrayList;
11+
import java.util.List;
1012
import java.util.Map;
1113
import java.util.function.Function;
1214
import lombok.NoArgsConstructor;
@@ -20,7 +22,8 @@
2022
* <li>Skip providers that indicate they had no value due to {@code FLAG_NOT_FOUND}.</li>
2123
* <li>On any other error code, return that error result.</li>
2224
* <li>If a provider throws {@link FlagNotFoundError}, it is treated like {@code FLAG_NOT_FOUND}.</li>
23-
* <li>If all providers report {@code FLAG_NOT_FOUND}, return a {@code FLAG_NOT_FOUND} error.</li>
25+
* <li>If all providers report {@code FLAG_NOT_FOUND}, return a {@code FLAG_NOT_FOUND} error
26+
* with per-provider error details.</li>
2427
* </ul>
2528
* As soon as a non-{@code FLAG_NOT_FOUND} result is returned by a provider (success or other error),
2629
* the rest of the operation short-circuits and does not call the remaining providers.
@@ -36,7 +39,11 @@ public <T> ProviderEvaluation<T> evaluate(
3639
T defaultValue,
3740
EvaluationContext ctx,
3841
Function<FeatureProvider, ProviderEvaluation<T>> providerFunction) {
39-
for (FeatureProvider provider : providers.values()) {
42+
List<ProviderError> collectedErrors = new ArrayList<>();
43+
44+
for (Map.Entry<String, FeatureProvider> entry : providers.entrySet()) {
45+
String providerName = entry.getKey();
46+
FeatureProvider provider = entry.getValue();
4047
try {
4148
ProviderEvaluation<T> res = providerFunction.apply(provider);
4249
ErrorCode errorCode = res.getErrorCode();
@@ -45,19 +52,22 @@ public <T> ProviderEvaluation<T> evaluate(
4552
return res;
4653
}
4754
if (!FLAG_NOT_FOUND.equals(errorCode)) {
48-
// Any non-FLAG_NOT_FOUND error bubbles up
55+
// Any non-FLAG_NOT_FOUND error bubbles up immediately
4956
return res;
5057
}
51-
// else FLAG_NOT_FOUND: skip to next provider
52-
} catch (FlagNotFoundError ignored) {
53-
// do not log in hot path, just skip
58+
// FLAG_NOT_FOUND: record and skip to next provider
59+
collectedErrors.add(ProviderError.fromResult(providerName, FLAG_NOT_FOUND, res.getErrorMessage()));
60+
} catch (FlagNotFoundError e) {
61+
// Treat thrown FlagNotFoundError like a FLAG_NOT_FOUND result
62+
collectedErrors.add(ProviderError.fromException(providerName, e));
5463
}
5564
}
5665

5766
// All providers either threw or returned FLAG_NOT_FOUND
58-
return ProviderEvaluation.<T>builder()
59-
.errorMessage("Flag not found in any provider")
67+
return MultiProviderEvaluation.<T>builder()
68+
.errorMessage(ProviderError.buildAggregateMessage("Flag not found in any provider", collectedErrors))
6069
.errorCode(FLAG_NOT_FOUND)
70+
.providerErrors(collectedErrors)
6171
.build();
6272
}
6373
}

src/main/java/dev/openfeature/sdk/multiprovider/FirstSuccessfulStrategy.java

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import dev.openfeature.sdk.EvaluationContext;
55
import dev.openfeature.sdk.FeatureProvider;
66
import dev.openfeature.sdk.ProviderEvaluation;
7+
import java.util.ArrayList;
8+
import java.util.List;
79
import java.util.Map;
810
import java.util.function.Function;
911
import lombok.NoArgsConstructor;
@@ -12,9 +14,10 @@
1214
/**
1315
* First Successful Strategy.
1416
*
15-
* <p>Similar to First Match, except that errors from evaluated providers do not halt execution.
17+
* <p>Similar to "First Match", except that errors from evaluated providers do not halt execution.
1618
* Instead, it returns the first successful result from a provider. If no provider successfully
17-
* responds, it returns a {@code GENERAL} error result.
19+
* responds, it returns a {@code GENERAL} error result that includes per-provider error details
20+
* describing why each provider failed.
1821
*/
1922
@Slf4j
2023
@NoArgsConstructor
@@ -27,22 +30,30 @@ public <T> ProviderEvaluation<T> evaluate(
2730
T defaultValue,
2831
EvaluationContext ctx,
2932
Function<FeatureProvider, ProviderEvaluation<T>> providerFunction) {
30-
for (FeatureProvider provider : providers.values()) {
33+
List<ProviderError> collectedErrors = new ArrayList<>();
34+
35+
for (Map.Entry<String, FeatureProvider> entry : providers.entrySet()) {
36+
String providerName = entry.getKey();
37+
FeatureProvider provider = entry.getValue();
3138
try {
3239
ProviderEvaluation<T> res = providerFunction.apply(provider);
3340
if (res.getErrorCode() == null) {
3441
// First successful result (no error code)
3542
return res;
3643
}
37-
} catch (Exception ignored) {
38-
// swallow and continue; errors from individual providers
39-
// are not fatal for this strategy
44+
// Record error-coded result
45+
collectedErrors.add(ProviderError.fromResult(providerName, res.getErrorCode(), res.getErrorMessage()));
46+
} catch (Exception e) {
47+
// Record thrown exception
48+
collectedErrors.add(ProviderError.fromException(providerName, e));
4049
}
4150
}
4251

43-
return ProviderEvaluation.<T>builder()
44-
.errorMessage("No provider successfully responded")
52+
return MultiProviderEvaluation.<T>builder()
53+
.errorMessage(
54+
ProviderError.buildAggregateMessage("No provider successfully responded", collectedErrors))
4555
.errorCode(ErrorCode.GENERAL)
56+
.providerErrors(collectedErrors)
4657
.build();
4758
}
4859
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package dev.openfeature.sdk.multiprovider;
2+
3+
import dev.openfeature.sdk.ProviderEvaluation;
4+
import java.util.Collections;
5+
import java.util.List;
6+
import lombok.Builder;
7+
import lombok.Getter;
8+
import lombok.experimental.SuperBuilder;
9+
10+
/**
11+
* A {@link ProviderEvaluation} subtype returned by multi-provider strategies that carries
12+
* per-provider error details.
13+
*
14+
* <p>This type can represent both successful and failed evaluations. When a strategy exhausts
15+
* all providers without a successful result, the per-provider errors describe why each provider
16+
* failed. Custom strategies may also use this type for successful results to surface information
17+
* about providers that were skipped or failed before the successful one.
18+
*
19+
* <p>Usage:
20+
* <pre>{@code
21+
* ProviderEvaluation<String> result = strategy.evaluate(...);
22+
* if (result instanceof MultiProviderEvaluation<String> multiResult) {
23+
* for (ProviderError error : multiResult.getProviderErrors()) {
24+
* log.warn("Provider {} failed: {} - {}",
25+
* error.getProviderName(), error.getErrorCode(), error.getErrorMessage());
26+
* }
27+
* }
28+
* }</pre>
29+
*
30+
* @param <T> the type of the flag being evaluated
31+
*/
32+
@Getter
33+
@SuperBuilder
34+
public class MultiProviderEvaluation<T> extends ProviderEvaluation<T> {
35+
36+
/**
37+
* Per-provider error details.
38+
*
39+
* <p>Each entry describes why a specific provider failed during multi-provider evaluation.
40+
* Defaults to an empty list when not set.
41+
*/
42+
@Builder.Default
43+
private List<ProviderError> providerErrors = Collections.emptyList();
44+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package dev.openfeature.sdk.multiprovider;
2+
3+
import dev.openfeature.sdk.ErrorCode;
4+
import dev.openfeature.sdk.exceptions.OpenFeatureError;
5+
import java.util.List;
6+
import java.util.stream.Collectors;
7+
import lombok.AllArgsConstructor;
8+
import lombok.Builder;
9+
import lombok.Data;
10+
11+
/**
12+
* Represents an error from a single provider during multi-provider evaluation.
13+
*
14+
* <p>Captures the provider name, error code, error message, and optionally the original exception
15+
* that occurred during flag evaluation. This allows callers to inspect per-provider error details
16+
* when a multi-provider strategy exhausts all providers without a successful result.
17+
*/
18+
@Data
19+
@Builder
20+
@AllArgsConstructor
21+
public class ProviderError {
22+
private String providerName;
23+
private ErrorCode errorCode;
24+
private String errorMessage;
25+
private Exception exception;
26+
27+
/**
28+
* Create a ProviderError from an error-coded {@code ProviderEvaluation} result.
29+
*
30+
* @param providerName the name of the provider that returned the error
31+
* @param errorCode the error code from the evaluation result
32+
* @param errorMessage the error message from the evaluation result (may be {@code null})
33+
* @return a new ProviderError
34+
*/
35+
public static ProviderError fromResult(String providerName, ErrorCode errorCode, String errorMessage) {
36+
return new ProviderError(providerName, errorCode, errorMessage, null);
37+
}
38+
39+
/**
40+
* Create a ProviderError from a thrown exception.
41+
*
42+
* @param providerName the name of the provider that threw the exception
43+
* @param exception the exception that was thrown
44+
* @return a new ProviderError
45+
*/
46+
public static ProviderError fromException(String providerName, Exception exception) {
47+
ErrorCode code = ErrorCode.GENERAL;
48+
if (exception instanceof OpenFeatureError) {
49+
code = ((OpenFeatureError) exception).getErrorCode();
50+
}
51+
return new ProviderError(providerName, code, exception.getMessage(), exception);
52+
}
53+
54+
/**
55+
* Build an aggregate error message from a list of provider errors.
56+
*
57+
* @param baseMessage the base message to use (e.g. "No provider successfully responded")
58+
* @param errors the list of per-provider errors
59+
* @return an aggregate message including per-provider details
60+
*/
61+
public static String buildAggregateMessage(String baseMessage, List<ProviderError> errors) {
62+
String details = errors.stream().map(ProviderError::toString).collect(Collectors.joining(", "));
63+
return baseMessage + ". Provider errors: [" + details + "]";
64+
}
65+
66+
@Override
67+
public String toString() {
68+
return providerName + ": " + errorCode + " (" + (errorMessage != null ? errorMessage : "unknown") + ")";
69+
}
70+
}

src/main/java/dev/openfeature/sdk/multiprovider/Strategy.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
* <li>Order or select providers</li>
1515
* <li>Handle {@code FLAG_NOT_FOUND} results</li>
1616
* <li>Handle errors and exceptions from providers</li>
17+
* <li>Collect per-provider error details when no provider returns a successful result.
18+
* Implementations should return a {@link MultiProviderEvaluation} populated with
19+
* a {@link ProviderError} for each failed provider, so that callers can inspect individual
20+
* failure reasons.</li>
1721
* </ul>
1822
*/
1923
public interface Strategy {

src/test/java/dev/openfeature/sdk/multiprovider/BaseStrategyTest.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,4 +211,17 @@ protected void setupProviderSuccess(FeatureProvider provider, String value) {
211211
when(provider.getStringEvaluation(BaseStrategyTest.FLAG_KEY, DEFAULT_STRING, null))
212212
.thenReturn(result);
213213
}
214+
215+
protected void setupProviderErrorWithMessage(FeatureProvider provider, ErrorCode errorCode, String errorMessage) {
216+
ProviderEvaluation<String> result = mock(ProviderEvaluation.class);
217+
when(result.getErrorCode()).thenReturn(errorCode);
218+
when(result.getErrorMessage()).thenReturn(errorMessage);
219+
when(provider.getStringEvaluation(BaseStrategyTest.FLAG_KEY, DEFAULT_STRING, null))
220+
.thenReturn(result);
221+
}
222+
223+
protected void setupProviderException(FeatureProvider provider, RuntimeException exception) {
224+
when(provider.getStringEvaluation(BaseStrategyTest.FLAG_KEY, DEFAULT_STRING, null))
225+
.thenThrow(exception);
226+
}
214227
}

src/test/java/dev/openfeature/sdk/multiprovider/FirstMatchStrategyTest.java

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package dev.openfeature.sdk.multiprovider;
22

33
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
45
import static org.junit.jupiter.api.Assertions.assertNotNull;
56
import static org.junit.jupiter.api.Assertions.assertNull;
7+
import static org.junit.jupiter.api.Assertions.assertTrue;
68

79
import dev.openfeature.sdk.ErrorCode;
810
import dev.openfeature.sdk.ProviderEvaluation;
11+
import dev.openfeature.sdk.exceptions.FlagNotFoundError;
12+
import java.util.List;
913
import org.junit.jupiter.api.Test;
1014

1115
class FirstMatchStrategyTest extends BaseStrategyTest {
@@ -59,19 +63,30 @@ void shouldReturnSuccessWhenFirstProviderSucceeds() {
5963
}
6064

6165
@Test
62-
void shouldThrowFlagNotFoundWhenAllProvidersReturnFlagNotFound() {
66+
void shouldReturnMultiProviderEvaluationWhenAllProvidersReturnFlagNotFound() {
6367
setupProviderFlagNotFound(mockProvider1);
6468
setupProviderFlagNotFound(mockProvider2);
6569
setupProviderFlagNotFound(mockProvider3);
66-
ProviderEvaluation<String> providerEvaluation = strategy.evaluate(
70+
ProviderEvaluation<String> result = strategy.evaluate(
6771
orderedProviders,
6872
FLAG_KEY,
6973
DEFAULT_STRING,
7074
null,
7175
p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null));
7276

73-
assertEquals(ErrorCode.FLAG_NOT_FOUND, providerEvaluation.getErrorCode());
74-
assertEquals("Flag not found in any provider", providerEvaluation.getErrorMessage());
77+
assertEquals(ErrorCode.FLAG_NOT_FOUND, result.getErrorCode());
78+
assertTrue(result.getErrorMessage().contains("Flag not found in any provider"));
79+
80+
MultiProviderEvaluation<String> multiResult = assertInstanceOf(MultiProviderEvaluation.class, result);
81+
List<ProviderError> errors = multiResult.getProviderErrors();
82+
assertNotNull(errors);
83+
assertEquals(3, errors.size());
84+
assertEquals("provider1", errors.get(0).getProviderName());
85+
assertEquals(ErrorCode.FLAG_NOT_FOUND, errors.get(0).getErrorCode());
86+
assertEquals("provider2", errors.get(1).getProviderName());
87+
assertEquals(ErrorCode.FLAG_NOT_FOUND, errors.get(1).getErrorCode());
88+
assertEquals("provider3", errors.get(2).getProviderName());
89+
assertEquals(ErrorCode.FLAG_NOT_FOUND, errors.get(2).getErrorCode());
7590
}
7691

7792
@Test
@@ -88,4 +103,53 @@ void shouldSkipMultipleFlagNotFoundAndReturnFirstOtherError() {
88103
assertNotNull(result);
89104
assertEquals(ErrorCode.PARSE_ERROR, result.getErrorCode());
90105
}
106+
107+
@Test
108+
void shouldCaptureThrownFlagNotFoundErrorsAsProviderErrors() {
109+
setupProviderException(mockProvider1, new FlagNotFoundError("not in provider1"));
110+
setupProviderException(mockProvider2, new FlagNotFoundError("not in provider2"));
111+
setupProviderException(mockProvider3, new FlagNotFoundError("not in provider3"));
112+
113+
ProviderEvaluation<String> result = strategy.evaluate(
114+
orderedProviders,
115+
FLAG_KEY,
116+
DEFAULT_STRING,
117+
null,
118+
p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null));
119+
120+
assertEquals(ErrorCode.FLAG_NOT_FOUND, result.getErrorCode());
121+
122+
MultiProviderEvaluation<String> multiResult = assertInstanceOf(MultiProviderEvaluation.class, result);
123+
List<ProviderError> errors = multiResult.getProviderErrors();
124+
assertNotNull(errors);
125+
assertEquals(3, errors.size());
126+
127+
assertEquals("provider1", errors.get(0).getProviderName());
128+
assertEquals(ErrorCode.FLAG_NOT_FOUND, errors.get(0).getErrorCode());
129+
assertEquals("not in provider1", errors.get(0).getErrorMessage());
130+
assertNotNull(errors.get(0).getException());
131+
132+
assertEquals("provider2", errors.get(1).getProviderName());
133+
assertEquals("provider3", errors.get(2).getProviderName());
134+
}
135+
136+
@Test
137+
void shouldIncludeProviderNamesInAggregateErrorMessage() {
138+
setupProviderFlagNotFound(mockProvider1);
139+
setupProviderFlagNotFound(mockProvider2);
140+
setupProviderFlagNotFound(mockProvider3);
141+
142+
ProviderEvaluation<String> result = strategy.evaluate(
143+
orderedProviders,
144+
FLAG_KEY,
145+
DEFAULT_STRING,
146+
null,
147+
p -> p.getStringEvaluation(FLAG_KEY, DEFAULT_STRING, null));
148+
149+
String message = result.getErrorMessage();
150+
assertTrue(message.contains("provider1"));
151+
assertTrue(message.contains("provider2"));
152+
assertTrue(message.contains("provider3"));
153+
assertTrue(message.contains("FLAG_NOT_FOUND"));
154+
}
91155
}

0 commit comments

Comments
 (0)