Skip to content

Commit 57b84a0

Browse files
committed
fix: collect and propagate per-provider errors in multi-provider strategies
Signed-off-by: Parth Suthar <parth.suthar@dynatrace.com>
1 parent 5070bb2 commit 57b84a0

9 files changed

Lines changed: 458 additions & 35 deletions

File tree

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

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
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;
14+
import java.util.stream.Collectors;
1215
import lombok.NoArgsConstructor;
1316
import lombok.extern.slf4j.Slf4j;
1417

@@ -20,7 +23,8 @@
2023
* <li>Skip providers that indicate they had no value due to {@code FLAG_NOT_FOUND}.</li>
2124
* <li>On any other error code, return that error result.</li>
2225
* <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>
26+
* <li>If all providers report {@code FLAG_NOT_FOUND}, return a {@code FLAG_NOT_FOUND} error
27+
* with per-provider error details.</li>
2428
* </ul>
2529
* As soon as a non-{@code FLAG_NOT_FOUND} result is returned by a provider (success or other error),
2630
* the rest of the operation short-circuits and does not call the remaining providers.
@@ -36,7 +40,11 @@ public <T> ProviderEvaluation<T> evaluate(
3640
T defaultValue,
3741
EvaluationContext ctx,
3842
Function<FeatureProvider, ProviderEvaluation<T>> providerFunction) {
39-
for (FeatureProvider provider : providers.values()) {
43+
List<ProviderError> collectedErrors = new ArrayList<>();
44+
45+
for (Map.Entry<String, FeatureProvider> entry : providers.entrySet()) {
46+
String providerName = entry.getKey();
47+
FeatureProvider provider = entry.getValue();
4048
try {
4149
ProviderEvaluation<T> res = providerFunction.apply(provider);
4250
ErrorCode errorCode = res.getErrorCode();
@@ -45,19 +53,31 @@ public <T> ProviderEvaluation<T> evaluate(
4553
return res;
4654
}
4755
if (!FLAG_NOT_FOUND.equals(errorCode)) {
48-
// Any non-FLAG_NOT_FOUND error bubbles up
56+
// Any non-FLAG_NOT_FOUND error bubbles up immediately
4957
return res;
5058
}
51-
// else FLAG_NOT_FOUND: skip to next provider
52-
} catch (FlagNotFoundError ignored) {
53-
// do not log in hot path, just skip
59+
// FLAG_NOT_FOUND: record and skip to next provider
60+
collectedErrors.add(ProviderError.fromResult(providerName, FLAG_NOT_FOUND, res.getErrorMessage()));
61+
} catch (FlagNotFoundError e) {
62+
// Treat thrown FlagNotFoundError like a FLAG_NOT_FOUND result
63+
collectedErrors.add(ProviderError.fromException(providerName, e));
5464
}
5565
}
5666

5767
// All providers either threw or returned FLAG_NOT_FOUND
58-
return ProviderEvaluation.<T>builder()
59-
.errorMessage("Flag not found in any provider")
68+
String aggregateMessage = buildAggregateMessage(collectedErrors);
69+
return MultiProviderEvaluation.<T>multiProviderBuilder()
70+
.errorMessage(aggregateMessage)
6071
.errorCode(FLAG_NOT_FOUND)
72+
.providerErrors(collectedErrors)
6173
.build();
6274
}
75+
76+
private static String buildAggregateMessage(List<ProviderError> errors) {
77+
if (errors.isEmpty()) {
78+
return "Flag not found in any provider";
79+
}
80+
String details = errors.stream().map(ProviderError::toString).collect(Collectors.joining(", "));
81+
return "Flag not found in any provider. Provider errors: [" + details + "]";
82+
}
6383
}

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

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,21 @@
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;
11+
import java.util.stream.Collectors;
912
import lombok.NoArgsConstructor;
1013
import lombok.extern.slf4j.Slf4j;
1114

1215
/**
1316
* First Successful Strategy.
1417
*
15-
* <p>Similar to First Match, except that errors from evaluated providers do not halt execution.
18+
* <p>Similar to "First Match", except that errors from evaluated providers do not halt execution.
1619
* Instead, it returns the first successful result from a provider. If no provider successfully
17-
* responds, it returns a {@code GENERAL} error result.
20+
* responds, it returns a {@code GENERAL} error result that includes per-provider error details
21+
* describing why each provider failed.
1822
*/
1923
@Slf4j
2024
@NoArgsConstructor
@@ -27,22 +31,38 @@ public <T> ProviderEvaluation<T> evaluate(
2731
T defaultValue,
2832
EvaluationContext ctx,
2933
Function<FeatureProvider, ProviderEvaluation<T>> providerFunction) {
30-
for (FeatureProvider provider : providers.values()) {
34+
List<ProviderError> collectedErrors = new ArrayList<>();
35+
36+
for (Map.Entry<String, FeatureProvider> entry : providers.entrySet()) {
37+
String providerName = entry.getKey();
38+
FeatureProvider provider = entry.getValue();
3139
try {
3240
ProviderEvaluation<T> res = providerFunction.apply(provider);
3341
if (res.getErrorCode() == null) {
3442
// First successful result (no error code)
3543
return res;
3644
}
37-
} catch (Exception ignored) {
38-
// swallow and continue; errors from individual providers
39-
// are not fatal for this strategy
45+
// Record error-coded result
46+
collectedErrors.add(ProviderError.fromResult(providerName, res.getErrorCode(), res.getErrorMessage()));
47+
} catch (Exception e) {
48+
// Record thrown exception
49+
collectedErrors.add(ProviderError.fromException(providerName, e));
4050
}
4151
}
4252

43-
return ProviderEvaluation.<T>builder()
44-
.errorMessage("No provider successfully responded")
53+
String aggregateMessage = buildAggregateMessage(collectedErrors);
54+
return MultiProviderEvaluation.<T>multiProviderBuilder()
55+
.errorMessage(aggregateMessage)
4556
.errorCode(ErrorCode.GENERAL)
57+
.providerErrors(collectedErrors)
4658
.build();
4759
}
60+
61+
private static String buildAggregateMessage(List<ProviderError> errors) {
62+
if (errors.isEmpty()) {
63+
return "No provider successfully responded";
64+
}
65+
String details = errors.stream().map(ProviderError::toString).collect(Collectors.joining(", "));
66+
return "No provider successfully responded. Provider errors: [" + details + "]";
67+
}
4868
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package dev.openfeature.sdk.multiprovider;
2+
3+
import dev.openfeature.sdk.ErrorCode;
4+
import dev.openfeature.sdk.ImmutableMetadata;
5+
import dev.openfeature.sdk.ProviderEvaluation;
6+
import java.util.Collections;
7+
import java.util.List;
8+
9+
/**
10+
* A {@link ProviderEvaluation} subtype returned by multi-provider strategies when all providers
11+
* fail to produce a successful result.
12+
*
13+
* <p>This type carries per-provider error details so that callers can inspect why each individual
14+
* provider failed. It is only returned in error scenarios; successful evaluations always return
15+
* a plain {@link ProviderEvaluation}.
16+
*
17+
* <p>Usage:
18+
* <pre>{@code
19+
* ProviderEvaluation<String> result = strategy.evaluate(...);
20+
* if (result instanceof MultiProviderEvaluation<String> multiResult) {
21+
* for (ProviderError error : multiResult.getProviderErrors()) {
22+
* log.warn("Provider {} failed: {} - {}",
23+
* error.getProviderName(), error.getErrorCode(), error.getErrorMessage());
24+
* }
25+
* }
26+
* }</pre>
27+
*
28+
* @param <T> the type of the flag being evaluated
29+
*/
30+
public class MultiProviderEvaluation<T> extends ProviderEvaluation<T> {
31+
32+
private final List<ProviderError> providerErrors;
33+
34+
private MultiProviderEvaluation(
35+
T value,
36+
String variant,
37+
String reason,
38+
ErrorCode errorCode,
39+
String errorMessage,
40+
ImmutableMetadata flagMetadata,
41+
List<ProviderError> providerErrors) {
42+
super(value, variant, reason, errorCode, errorMessage, flagMetadata);
43+
this.providerErrors =
44+
providerErrors != null ? Collections.unmodifiableList(providerErrors) : Collections.emptyList();
45+
}
46+
47+
/**
48+
* Returns the per-provider error details.
49+
*
50+
* <p>Each entry describes why a specific provider failed during multi-provider evaluation.
51+
*
52+
* @return an unmodifiable list of per-provider errors, never {@code null}
53+
*/
54+
public List<ProviderError> getProviderErrors() {
55+
return providerErrors;
56+
}
57+
58+
/**
59+
* Create a new builder for {@link MultiProviderEvaluation}.
60+
*
61+
* @param <T> the flag value type
62+
* @return a new builder
63+
*/
64+
public static <T> Builder<T> multiProviderBuilder() {
65+
return new Builder<>();
66+
}
67+
68+
/**
69+
* Builder for {@link MultiProviderEvaluation}.
70+
*
71+
* @param <T> the flag value type
72+
*/
73+
public static class Builder<T> {
74+
private T value;
75+
private String variant;
76+
private String reason;
77+
private ErrorCode errorCode;
78+
private String errorMessage;
79+
private ImmutableMetadata flagMetadata;
80+
private List<ProviderError> providerErrors;
81+
82+
public Builder<T> value(T value) {
83+
this.value = value;
84+
return this;
85+
}
86+
87+
public Builder<T> variant(String variant) {
88+
this.variant = variant;
89+
return this;
90+
}
91+
92+
public Builder<T> reason(String reason) {
93+
this.reason = reason;
94+
return this;
95+
}
96+
97+
public Builder<T> errorCode(ErrorCode errorCode) {
98+
this.errorCode = errorCode;
99+
return this;
100+
}
101+
102+
public Builder<T> errorMessage(String errorMessage) {
103+
this.errorMessage = errorMessage;
104+
return this;
105+
}
106+
107+
public Builder<T> flagMetadata(ImmutableMetadata flagMetadata) {
108+
this.flagMetadata = flagMetadata;
109+
return this;
110+
}
111+
112+
public Builder<T> providerErrors(List<ProviderError> providerErrors) {
113+
this.providerErrors = providerErrors;
114+
return this;
115+
}
116+
117+
public MultiProviderEvaluation<T> build() {
118+
return new MultiProviderEvaluation<>(
119+
value, variant, reason, errorCode, errorMessage, flagMetadata, providerErrors);
120+
}
121+
}
122+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package dev.openfeature.sdk.multiprovider;
2+
3+
import dev.openfeature.sdk.ErrorCode;
4+
import dev.openfeature.sdk.exceptions.OpenFeatureError;
5+
import lombok.AllArgsConstructor;
6+
import lombok.Builder;
7+
import lombok.Data;
8+
9+
/**
10+
* Represents an error from a single provider during multi-provider evaluation.
11+
*
12+
* <p>Captures the provider name, error code, error message, and optionally the original exception
13+
* that occurred during flag evaluation. This allows callers to inspect per-provider error details
14+
* when a multi-provider strategy exhausts all providers without a successful result.
15+
*/
16+
@Data
17+
@Builder
18+
@AllArgsConstructor
19+
public class ProviderError {
20+
private String providerName;
21+
private ErrorCode errorCode;
22+
private String errorMessage;
23+
private Exception exception;
24+
25+
/**
26+
* Create a ProviderError from an error-coded {@code ProviderEvaluation} result.
27+
*
28+
* @param providerName the name of the provider that returned the error
29+
* @param errorCode the error code from the evaluation result
30+
* @param errorMessage the error message from the evaluation result (may be {@code null})
31+
* @return a new ProviderError
32+
*/
33+
public static ProviderError fromResult(String providerName, ErrorCode errorCode, String errorMessage) {
34+
return new ProviderError(providerName, errorCode, errorMessage, null);
35+
}
36+
37+
/**
38+
* Create a ProviderError from a thrown exception.
39+
*
40+
* @param providerName the name of the provider that threw the exception
41+
* @param exception the exception that was thrown
42+
* @return a new ProviderError
43+
*/
44+
public static ProviderError fromException(String providerName, Exception exception) {
45+
ErrorCode code = ErrorCode.GENERAL;
46+
if (exception instanceof OpenFeatureError) {
47+
code = ((OpenFeatureError) exception).getErrorCode();
48+
}
49+
return new ProviderError(providerName, code, exception.getMessage(), exception);
50+
}
51+
52+
@Override
53+
public String toString() {
54+
return providerName + ": " + errorCode + " (" + (errorMessage != null ? errorMessage : "unknown") + ")";
55+
}
56+
}

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
}

0 commit comments

Comments
 (0)