From c519ae617109cf3158cd423991206edb8043f56c Mon Sep 17 00:00:00 2001 From: novalisdenahi Date: Fri, 12 Jun 2026 15:35:00 +0200 Subject: [PATCH 1/7] WIP Enhance error reporting with detailed evaluation and refresh error codes --- .../java/com/configcat/ConfigCatClient.java | 45 ++++++++++------- .../java/com/configcat/ConfigFetcher.java | 28 ++++++----- .../java/com/configcat/ConfigService.java | 8 +-- .../java/com/configcat/EvaluationDetails.java | 16 ++++-- .../com/configcat/EvaluationErrorCode.java | 40 +++++++++++++++ .../java/com/configcat/RefreshErrorCode.java | 49 +++++++++++++++++++ .../java/com/configcat/RefreshResult.java | 10 +++- .../java/com/configcat/RolloutEvaluator.java | 44 ++++++++++------- src/main/java/com/configcat/Utils.java | 19 +++++-- .../com/configcat/ConfigCatClientTest.java | 21 ++++++++ 10 files changed, 219 insertions(+), 61 deletions(-) create mode 100644 src/main/java/com/configcat/EvaluationErrorCode.java create mode 100644 src/main/java/com/configcat/RefreshErrorCode.java diff --git a/src/main/java/com/configcat/ConfigCatClient.java b/src/main/java/com/configcat/ConfigCatClient.java index 089a273..055c884 100644 --- a/src/main/java/com/configcat/ConfigCatClient.java +++ b/src/main/java/com/configcat/ConfigCatClient.java @@ -92,7 +92,7 @@ public T getValue(Class classOfT, String key, User user, T defaultValue) private static void validateReturnType(Class classOfT) { if (!(classOfT == String.class || classOfT == Integer.class || classOfT == int.class || classOfT == Double.class || classOfT == double.class || classOfT == Boolean.class || classOfT == boolean.class)) { - throw new IllegalArgumentException("Only String, Integer, Double or Boolean types are supported."); + throw new EvaluationException("Only String, Integer, Double or Boolean types are supported."); } } @@ -121,19 +121,16 @@ public EvaluationDetails getValueDetails(Class classOfT, String key, T public EvaluationDetails getValueDetails(Class classOfT, String key, User user, T defaultValue) { if (key == null || key.isEmpty()) throw new IllegalArgumentException("'key' cannot be null or empty."); - - validateReturnType(classOfT); - try { return this.getValueDetailsAsync(classOfT, key, user, defaultValue).get(); } catch (InterruptedException e) { + Thread.currentThread().interrupt(); String error = "Thread interrupted."; this.logger.error(0, error, e); - Thread.currentThread().interrupt(); - return EvaluationDetails.fromError(key, defaultValue, error + ": " + e.getMessage(), user); + return EvaluationDetails.fromError(key, defaultValue, EvaluationErrorCode.UNEXPECTED_ERROR,error + ": " + e.getMessage(), user); } catch (Exception e) { this.logger.error(1002, ConfigCatLogMessages.getSettingEvaluationErrorWithDefaultValue("getValueDetails", key, "defaultValue", defaultValue), e); - return EvaluationDetails.fromError(key, defaultValue, e.getMessage(), user); + return EvaluationDetails.fromError(key, defaultValue, EvaluationErrorCode.UNEXPECTED_ERROR, e.getMessage(), user); } } @@ -147,19 +144,30 @@ public CompletableFuture> getValueDetailsAsync(Class if (key == null || key.isEmpty()) throw new IllegalArgumentException("'key' cannot be null or empty."); - validateReturnType(classOfT); + try { + validateReturnType(classOfT); - return this.getSettingsAsync() + return this.getSettingsAsync() .thenApply(settingsResult -> { Result checkSettingResult = checkSettingAvailable(settingsResult, key, defaultValue); if (checkSettingResult.error() != null) { - EvaluationDetails evaluationDetails = EvaluationDetails.fromError(key, defaultValue, checkSettingResult.error(), user); + EvaluationDetails evaluationDetails = EvaluationDetails.fromError(key, defaultValue, checkSettingResult.errorCode(), checkSettingResult.error(), user); this.hooks.invokeOnFlagEvaluated(evaluationDetails); return evaluationDetails.asTypeSpecific(); } return this.evaluate(classOfT, checkSettingResult.value(), key, user != null ? user : this.defaultUser, settingsResult.fetchTime(), settingsResult.settings()); }); + } catch (InvalidConfigModelException e) { + this.logger.error(1002, ConfigCatLogMessages.getSettingEvaluationErrorWithDefaultValue("getValueDetails", key, "defaultValue", defaultValue), e); + return CompletableFuture.completedFuture(EvaluationDetails.fromError(key, defaultValue, EvaluationErrorCode.INVALID_CONFIG_MODEL, e.getMessage(), user)); + } catch (EvaluationException e) { + this.logger.error(1002, ConfigCatLogMessages.getSettingEvaluationErrorWithDefaultValue("getValueDetails", key, "defaultValue", defaultValue), e); + return CompletableFuture.completedFuture(EvaluationDetails.fromError(key, defaultValue, EvaluationErrorCode.SETTING_VALUE_TYPE_MISMATCH, e.getMessage(), user)); + } catch (Exception e) { + this.logger.error(1002, ConfigCatLogMessages.getSettingEvaluationErrorWithDefaultValue("getValueDetails", key, "defaultValue", defaultValue), e); + return CompletableFuture.completedFuture(EvaluationDetails.fromError(key, defaultValue, EvaluationErrorCode.UNEXPECTED_ERROR, e.getMessage(), user)); + } } @Override @@ -331,14 +339,14 @@ public RefreshResult forceRefresh() { } catch (Exception e) { this.logger.error(1003, ConfigCatLogMessages.getForceRefreshError("forceRefresh"), e); } - return new RefreshResult(false, "An error occurred during the refresh."); + return new RefreshResult(false, "An error occurred during the refresh.", RefreshErrorCode.UNEXPECTED_ERROR); } @Override public CompletableFuture forceRefreshAsync() { if (configService == null) { return CompletableFuture.completedFuture(new RefreshResult(false, - "The ConfigCat SDK is in local-only mode. Calling .forceRefresh() has no effect.")); + "The ConfigCat SDK is in local-only mode. Calling .forceRefresh() has no effect.", RefreshErrorCode.LOCAL_ONLY_CLIENT)); } return configService.refresh(); @@ -449,13 +457,13 @@ private T getValueFromSettingsMap(Class classOfT, SettingResult settingRe try { Result checkSettingResult = checkSettingAvailable(settingResult, key, defaultValue); if (checkSettingResult.error() != null) { - this.hooks.invokeOnFlagEvaluated(EvaluationDetails.fromError(key, defaultValue, checkSettingResult.error(), user)); + this.hooks.invokeOnFlagEvaluated(EvaluationDetails.fromError(key, defaultValue, checkSettingResult.errorCode(), checkSettingResult.error(), user)); return defaultValue; } return this.evaluate(classOfT, checkSettingResult.value(), key, userObject, settingResult.fetchTime(), settingResult.settings()).getValue(); } catch (Exception | NoSuchMethodError e) { FormattableLogMessage error = ConfigCatLogMessages.getSettingEvaluationFailedForOtherReason(key, "defaultValue", defaultValue); - this.hooks.invokeOnFlagEvaluated(EvaluationDetails.fromError(key, defaultValue, error + " " + e.getMessage(), userObject)); + this.hooks.invokeOnFlagEvaluated(EvaluationDetails.fromError(key, defaultValue, EvaluationErrorCode.UNEXPECTED_ERROR, error + " " + e.getMessage(), userObject)); this.logger.error(2001, error, e); return defaultValue; } @@ -518,6 +526,7 @@ private EvaluationDetails evaluate(Class classOfT, Setting setting, St user, false, null, + EvaluationErrorCode.NONE, fetchTime, evaluationResult.targetingRule, evaluationResult.percentageOption); @@ -537,7 +546,7 @@ else if ((classOfT == Double.class || classOfT == double.class) && settingValue. else if ((classOfT == Boolean.class || classOfT == boolean.class) && settingValue.getBooleanValue() != null && SettingType.BOOLEAN.equals(settingType)) return settingValue.getBooleanValue(); - throw new IllegalArgumentException("The type of a setting must match the type of the specified default value. " + throw new EvaluationException("The type of a setting must match the type of the specified default value. " + "Setting's type was {" + settingType + "} but the default value's type was {" + classOfT + "}. " + "Please use a default value which corresponds to the setting type {" + settingType + "}." + "Learn more: https://configcat.com/docs/sdk-reference/android/#setting-type-mapping"); @@ -553,7 +562,7 @@ else if (settingType == SettingType.INT) else if (settingType == SettingType.DOUBLE) return double.class; else - throw new IllegalArgumentException("Only String, Integer, Double or Boolean types are supported"); + throw new EvaluationException("Only String, Integer, Double or Boolean types are supported"); } private boolean checkSettingsAvailable(SettingResult settingResult, String emptyResult) { @@ -569,7 +578,7 @@ private Result checkSettingAvailable(SettingResult settingResult, S if (settingResult.isEmpty()) { FormattableLogMessage errorMessage = ConfigCatLogMessages.getConfigJsonIsNotPresentedWithDefaultValue(key, "defaultValue", defaultValue); this.logger.error(1000, errorMessage); - return Result.error(errorMessage, null); + return Result.error(errorMessage, null, EvaluationErrorCode.CONFIG_JSON_NOT_AVAILABLE); } Map settings = settingResult.settings(); @@ -577,7 +586,7 @@ private Result checkSettingAvailable(SettingResult settingResult, S if (setting == null) { FormattableLogMessage errorMessage = ConfigCatLogMessages.getSettingEvaluationFailedDueToMissingKey(key, "defaultValue", defaultValue, settings.keySet()); this.logger.error(1001, errorMessage); - return Result.error(errorMessage, null); + return Result.error(errorMessage, null, EvaluationErrorCode.SETTING_KEY_MISSING); } return Result.success(setting); diff --git a/src/main/java/com/configcat/ConfigFetcher.java b/src/main/java/com/configcat/ConfigFetcher.java index a6bebbd..c0d47a5 100644 --- a/src/main/java/com/configcat/ConfigFetcher.java +++ b/src/main/java/com/configcat/ConfigFetcher.java @@ -22,6 +22,7 @@ public enum Status { private final Status status; private final Entry entry; private final Object error; + private final RefreshErrorCode errorCode; private final boolean fetchTimeUpdatable; private final String cfRayId; @@ -49,26 +50,29 @@ public Object error() { return this.error; } + public RefreshErrorCode errorCode() {return this.errorCode;} + public String cfRayId() {return this.cfRayId;} - FetchResponse(Status status, Entry entry, Object error, boolean fetchTimeUpdatable, String cfRayId) { + FetchResponse(Status status, Entry entry, Object error, RefreshErrorCode errorCode, boolean fetchTimeUpdatable, String cfRayId) { this.status = status; this.entry = entry; this.error = error; + this.errorCode = errorCode; this.fetchTimeUpdatable = fetchTimeUpdatable; this.cfRayId = cfRayId; } public static FetchResponse fetched(Entry entry, String cfRayId) { - return new FetchResponse(Status.FETCHED, entry == null ? Entry.EMPTY : entry, null, false, cfRayId); + return new FetchResponse(Status.FETCHED, entry == null ? Entry.EMPTY : entry, null, RefreshErrorCode.NONE, false, cfRayId); } public static FetchResponse notModified(String cfRayId) { - return new FetchResponse(Status.NOT_MODIFIED, Entry.EMPTY, null, true, cfRayId); + return new FetchResponse(Status.NOT_MODIFIED, Entry.EMPTY, null, RefreshErrorCode.NONE, true, cfRayId); } - public static FetchResponse failed(Object error, boolean fetchTimeUpdatable, String cfRayId) { - return new FetchResponse(Status.FAILED, Entry.EMPTY, error, fetchTimeUpdatable, cfRayId); + public static FetchResponse failed(Object error, RefreshErrorCode errorCode, boolean fetchTimeUpdatable, String cfRayId) { + return new FetchResponse(Status.FAILED, Entry.EMPTY, error,errorCode, fetchTimeUpdatable, cfRayId); } } @@ -192,7 +196,7 @@ private void callHTTP(String previousETag, CompletableFuture resu String eTag = readHeaderValue(responseHeaders,"ETag"); Result configResult = deserializeConfig(content, cfRayId); if (configResult.error() != null) { - fetchResponse = FetchResponse.failed(configResult.error(), false, cfRayId); + fetchResponse = FetchResponse.failed(configResult.error(), RefreshErrorCode.INVALID_HTTP_RESPONSE_CONTENT, false, cfRayId); } else { logger.debug("Fetch was successful: new config fetched."); fetchResponse = FetchResponse.fetched(new Entry(configResult.value(), eTag, content, System.currentTimeMillis()), cfRayId); @@ -207,24 +211,24 @@ private void callHTTP(String previousETag, CompletableFuture resu } else if (responseCode == 403 || responseCode == 404) { FormattableLogMessage message = ConfigCatLogMessages.getFetchFailedDueToInvalidSDKKey(cfRayId); logger.error(1100, message); - fetchResponse = FetchResponse.failed(message, true, cfRayId); + fetchResponse = FetchResponse.failed(message, RefreshErrorCode.INVALID_SDK_KEY, true, cfRayId); } else { FormattableLogMessage message = ConfigCatLogMessages.getFetchFailedDueToUnexpectedHttpResponse(responseCode, urlConnection.getResponseMessage(), cfRayId); logger.error(1101, message); - fetchResponse = FetchResponse.failed(message, false, cfRayId); + fetchResponse = FetchResponse.failed(message, RefreshErrorCode.UNEXPECTED_HTTP_RESPONSE, false, cfRayId); } } catch (SocketTimeoutException e) { FormattableLogMessage message = ConfigCatLogMessages.getFetchFailedDueToRequestTimeout(httpOptions.getConnectTimeoutMillis(), httpOptions.getReadTimeoutMillis(), cfRayId); logger.error(1102, message, e); - fetchResponse = FetchResponse.failed(message, false, cfRayId); + fetchResponse = FetchResponse.failed(message, RefreshErrorCode.HTTP_REQUEST_TIMEOUT, false, cfRayId); } catch (Exception e) { FormattableLogMessage message = ConfigCatLogMessages.getFetchFailedDueToUnexpectedError(cfRayId); logger.error(1103, message, e); - fetchResponse = FetchResponse.failed(message + " " + e.getMessage(), false, cfRayId); + fetchResponse = FetchResponse.failed(message, RefreshErrorCode.HTTP_REQUEST_FAILURE, false, cfRayId); } finally { if(fetchResponse == null) { - fetchResponse = FetchResponse.failed(ConfigCatLogMessages.getFetchFailedDueToUnexpectedError(cfRayId), false, cfRayId); + fetchResponse = FetchResponse.failed(ConfigCatLogMessages.getFetchFailedDueToUnexpectedError(cfRayId), RefreshErrorCode.UNEXPECTED_ERROR, false, cfRayId); } result.complete(fetchResponse); if (urlConnection != null) { @@ -277,7 +281,7 @@ private Result deserializeConfig(String json, String cfRayId) { } catch (Exception e) { FormattableLogMessage message = ConfigCatLogMessages.getFetchReceived200WithInvalidBodyError(cfRayId); this.logger.error(1105, message, e); - return Result.error(message, null); + return Result.error(message, null, EvaluationErrorCode.INVALID_CONFIG_MODEL); } } } diff --git a/src/main/java/com/configcat/ConfigService.java b/src/main/java/com/configcat/ConfigService.java index 6b0808e..ca7ec79 100644 --- a/src/main/java/com/configcat/ConfigService.java +++ b/src/main/java/com/configcat/ConfigService.java @@ -96,7 +96,7 @@ public ConfigService(String sdkKey, hooks.invokeOnClientReady(determineCacheState()); FormattableLogMessage message = ConfigCatLogMessages.getAutoPollMaxInitWaitTimeReached(autoPollingMode.getMaxInitWaitTimeSeconds()); logger.warn(4200, message); - completeRunningTask(Result.error(message, cachedEntry)); + completeRunningTask(Result.error(message, cachedEntry, null)); //TODO fix me } } finally { lock.unlock(); @@ -133,11 +133,11 @@ public CompletableFuture refresh() { if (isOffline()) { String offlineWarning = ConfigCatLogMessages.CONFIG_SERVICE_CANNOT_INITIATE_HTTP_CALLS_WARN; logger.warn(3200, offlineWarning); - return CompletableFuture.completedFuture(new RefreshResult(false, offlineWarning)); + return CompletableFuture.completedFuture(new RefreshResult(false, offlineWarning, RefreshErrorCode.OFFLINE_CLIENT)); } return fetchIfOlder(Constants.DISTANT_FUTURE, false) - .thenApply(entryResult -> new RefreshResult(entryResult.error() == null, entryResult.error())); + .thenApply(entryResult -> new RefreshResult(entryResult.error() == null, entryResult.error(), null)); //TODO fixme } public void setOnline() { @@ -216,7 +216,7 @@ private void processResponse(FetchResponse response) { writeCache(cachedEntry); } completeRunningTask(response.isFailed() - ? Result.error(response.error(), cachedEntry) + ? Result.error(response.error(), cachedEntry, null) //TODO fixme : Result.success(cachedEntry)); } setInitialized(); diff --git a/src/main/java/com/configcat/EvaluationDetails.java b/src/main/java/com/configcat/EvaluationDetails.java index 3609e49..54c0cd6 100644 --- a/src/main/java/com/configcat/EvaluationDetails.java +++ b/src/main/java/com/configcat/EvaluationDetails.java @@ -10,6 +10,7 @@ public class EvaluationDetails { private final User user; private final boolean isDefaultValue; private final Object error; + private final EvaluationErrorCode errorCode; private final long fetchTimeUnixMilliseconds; private final TargetingRule matchedTargetingRule; private final PercentageOption matchedPercentageOption; @@ -20,6 +21,7 @@ public EvaluationDetails(T value, User user, boolean isDefaultValue, Object error, + EvaluationErrorCode errorCode, long fetchTimeUnixMilliseconds, TargetingRule matchedTargetingRule, PercentageOption matchedPercentageOption) { @@ -28,18 +30,19 @@ public EvaluationDetails(T value, this.variationId = variationId; this.user = user; this.isDefaultValue = isDefaultValue; + this.errorCode = errorCode; this.error = error; this.fetchTimeUnixMilliseconds = fetchTimeUnixMilliseconds; this.matchedTargetingRule = matchedTargetingRule; this.matchedPercentageOption = matchedPercentageOption; } - static EvaluationDetails fromError(String key, T defaultValue, Object error, User user) { - return new EvaluationDetails<>(defaultValue, key, "", user, true, error, Constants.DISTANT_PAST, null, null); + static EvaluationDetails fromError(String key, T defaultValue, EvaluationErrorCode errorCode, Object error, User user) { + return new EvaluationDetails<>(defaultValue, key, "", user, true, error, errorCode, Constants.DISTANT_PAST, null, null); } EvaluationDetails asTypeSpecific() { - return new EvaluationDetails<>((TR) value, key, variationId, user, isDefaultValue, error, fetchTimeUnixMilliseconds, matchedTargetingRule, matchedPercentageOption); + return new EvaluationDetails<>((TR) value, key, variationId, user, isDefaultValue, error, errorCode, fetchTimeUnixMilliseconds, matchedTargetingRule, matchedPercentageOption); } /** @@ -87,6 +90,13 @@ public String getError() { return null; } + /** + * The error code of the evaluation result. If the evaluation was successful, this will be EvaluationErrorCode.NONE. + */ + public EvaluationErrorCode getErrorCode() { + return errorCode; + } + /** * The last fetch time of the config.json in unix milliseconds format. */ diff --git a/src/main/java/com/configcat/EvaluationErrorCode.java b/src/main/java/com/configcat/EvaluationErrorCode.java new file mode 100644 index 0000000..736e41c --- /dev/null +++ b/src/main/java/com/configcat/EvaluationErrorCode.java @@ -0,0 +1,40 @@ +package com.configcat; + +/** + * Specifies the possible evaluation error codes. + */ +public enum EvaluationErrorCode { + + /** An unexpected error occurred during the evaluation. */ + UNEXPECTED_ERROR(-1), + + /** No error occurred (the evaluation was successful). */ + NONE(0), + + /** + * The evaluation failed because of an error in the config model. + * (Most likely, invalid data was passed to the SDK via flag overrides.) + */ + INVALID_CONFIG_MODEL(1), + + /** + * The evaluation failed because of a type mismatch between the evaluated + * setting value and the specified default value. + */ + SETTING_VALUE_TYPE_MISMATCH(2), + + /** The evaluation failed because the config JSON was not available locally. */ + CONFIG_JSON_NOT_AVAILABLE(1000), + + /** + * The evaluation failed because the key of the evaluated setting was not found in + * the config JSON. + */ + SETTING_KEY_MISSING(1001); + + public final int code; + + EvaluationErrorCode(int code) { + this.code = code; + } +} diff --git a/src/main/java/com/configcat/RefreshErrorCode.java b/src/main/java/com/configcat/RefreshErrorCode.java new file mode 100644 index 0000000..63f17aa --- /dev/null +++ b/src/main/java/com/configcat/RefreshErrorCode.java @@ -0,0 +1,49 @@ +package com.configcat; + +/** + * Specifies the possible config data refresh error codes. + */ +public enum RefreshErrorCode { + + /** An unexpected error occurred during the refresh operation. */ + UNEXPECTED_ERROR(-1), + + /** No error occurred (the refresh operation was successful). */ + NONE(0), + + /** + * The refresh operation failed because the client is configured to use the `OverrideBehaviour.LocalOnly` + * override behavior, which prevents synchronization with the external cache and making HTTP requests. + */ + LOCAL_ONLY_CLIENT(1), + + /** The refresh operation failed because the client is in offline mode, it cannot initiate HTTP requests. */ + OFFLINE_CLIENT(3200), + + /** + * The refresh operation failed because a HTTP response indicating an + * invalid SDK Key was received (403 Forbidden or 404 Not Found). + */ + INVALID_SDK_KEY(1100), + + /** The refresh operation failed because an invalid HTTP response was received (unexpected HTTP status code). */ + UNEXPECTED_HTTP_RESPONSE(1101), + + /** The refresh operation failed because the HTTP request timed out. */ + HTTP_REQUEST_TIMEOUT(1102), + + /** The refresh operation failed because the HTTP request failed (most likely, due to a local network issue). */ + HTTP_REQUEST_FAILURE(1103), + + /** The refresh operation failed because an invalid HTTP response was received (200 OK with an invalid content). */ + INVALID_HTTP_RESPONSE_CONTENT(1105), + + /** Initialization of the SDK timed out. **/ + CLIENT_INIT_TIMED_OUT(4200); + + public final int code; + + RefreshErrorCode(int code) { + this.code = code; + } +} diff --git a/src/main/java/com/configcat/RefreshResult.java b/src/main/java/com/configcat/RefreshResult.java index f382201..78e41c1 100644 --- a/src/main/java/com/configcat/RefreshResult.java +++ b/src/main/java/com/configcat/RefreshResult.java @@ -1,15 +1,19 @@ package com.configcat; +import java.util.Map; + /** * Represents the result of a forceRefresh() call. */ public class RefreshResult { private final boolean success; private final Object error; + private final RefreshErrorCode errorCode; - RefreshResult(boolean success, Object error) { + RefreshResult(boolean success, Object error, RefreshErrorCode errorCode) { this.success = success; this.error = error; + this.errorCode = errorCode; } public boolean isSuccess() { @@ -22,4 +26,8 @@ public String error() { } return null; } + + public RefreshErrorCode errorCode() { + return errorCode; + } } diff --git a/src/main/java/com/configcat/RolloutEvaluator.java b/src/main/java/com/configcat/RolloutEvaluator.java index 43eb83e..b2370ae 100644 --- a/src/main/java/com/configcat/RolloutEvaluator.java +++ b/src/main/java/com/configcat/RolloutEvaluator.java @@ -93,7 +93,7 @@ private EvaluationResult evaluateTargetingRules(Setting setting, EvaluationConte } if (rule.getPercentageOptions() == null || rule.getPercentageOptions().length == 0) { - throw new IllegalArgumentException("Targeting rule THEN part is missing or invalid."); + throw new InvalidConfigModelException( "Targeting rule THEN part is missing or invalid."); } evaluateLogger.increaseIndentLevel(); @@ -193,7 +193,7 @@ private boolean evaluateUserCondition(UserCondition userCondition, EvaluationCon } if (comparator == null) { - throw new IllegalArgumentException(COMPARISON_OPERATOR_IS_INVALID); + throw new InvalidConfigModelException( COMPARISON_OPERATOR_IS_INVALID); } switch (comparator) { case CONTAINS_ANY_OF: @@ -265,7 +265,7 @@ private boolean evaluateUserCondition(UserCondition userCondition, EvaluationCon String[] userAttributeAsStringArray = getUserAttributeAsStringArray(userCondition, context, comparisonAttribute, userAttributeValue); return evaluateArrayContains(userCondition, configSalt, contextSalt, userAttributeAsStringArray, negateArrayContains, hashedArrayContains); default: - throw new IllegalArgumentException(COMPARISON_OPERATOR_IS_INVALID); + throw new InvalidConfigModelException( COMPARISON_OPERATOR_IS_INVALID); } } @@ -399,21 +399,21 @@ private boolean evaluateHashedStartOrEndsWith(UserCondition userCondition, Strin for (String comparisonValueHashedStartsEnds : comparisonValues) { int indexOf = ensureComparisonValue(comparisonValueHashedStartsEnds).indexOf("_"); if (indexOf <= 0) { - throw new IllegalArgumentException(COMPARISON_VALUE_IS_MISSING_OR_INVALID); + throw new InvalidConfigModelException( COMPARISON_VALUE_IS_MISSING_OR_INVALID); } String comparedTextLength = comparisonValueHashedStartsEnds.substring(0, indexOf).trim(); int comparedTextLengthInt; try { comparedTextLengthInt = Integer.parseInt(comparedTextLength); } catch (NumberFormatException e) { - throw new IllegalArgumentException(COMPARISON_VALUE_IS_MISSING_OR_INVALID); + throw new InvalidConfigModelException( COMPARISON_VALUE_IS_MISSING_OR_INVALID); } if (userAttributeValueUTF8.length < comparedTextLengthInt) { continue; } String comparisonHashValue = comparisonValueHashedStartsEnds.substring(indexOf + 1); if (comparisonHashValue.isEmpty()) { - throw new IllegalArgumentException(COMPARISON_VALUE_IS_MISSING_OR_INVALID); + throw new InvalidConfigModelException( COMPARISON_VALUE_IS_MISSING_OR_INVALID); } byte[] userValueSubStringByteArray; if (UserComparator.HASHED_STARTS_WITH.equals(comparator) || UserComparator.HASHED_NOT_STARTS_WITH.equals(comparator)) { @@ -550,11 +550,11 @@ private boolean evaluateSegmentCondition(SegmentCondition segmentCondition, Eval } if (segment == null) { - throw new IllegalArgumentException("Segment reference is invalid."); + throw new InvalidConfigModelException( "Segment reference is invalid."); } String segmentName = segment.getName(); if (segmentName == null || segmentName.isEmpty()) { - throw new IllegalArgumentException("Segment name is missing."); + throw new InvalidConfigModelException( "Segment name is missing."); } evaluateLogger.logSegmentEvaluationStart(segmentName); @@ -564,7 +564,7 @@ private boolean evaluateSegmentCondition(SegmentCondition segmentCondition, Eval SegmentComparator segmentComparator = SegmentComparator.fromId(segmentCondition.getSegmentComparator()); if (segmentComparator == null) { - throw new IllegalArgumentException("Segment comparison operator is invalid."); + throw new InvalidConfigModelException( "Segment comparison operator is invalid."); } switch (segmentComparator) { case IS_IN_SEGMENT: @@ -574,7 +574,7 @@ private boolean evaluateSegmentCondition(SegmentCondition segmentCondition, Eval result = !segmentRulesResult; break; default: - throw new IllegalArgumentException("Segment comparison operator is invalid."); + throw new InvalidConfigModelException( "Segment comparison operator is invalid."); } evaluateLogger.logSegmentEvaluationResult(segmentCondition, segment, result, segmentRulesResult); @@ -593,7 +593,7 @@ private boolean evaluatePrerequisiteFlagCondition(PrerequisiteFlagCondition prer String prerequisiteFlagKey = prerequisiteFlagCondition.getPrerequisiteFlagKey(); Setting prerequisiteFlagSetting = context.getSettings().get(prerequisiteFlagKey); if (prerequisiteFlagKey == null || prerequisiteFlagKey.isEmpty() || prerequisiteFlagSetting == null) { - throw new IllegalArgumentException("Prerequisite flag key is missing or invalid."); + throw new InvalidConfigModelException( "Prerequisite flag key is missing or invalid."); } SettingType settingType = prerequisiteFlagSetting .getType(); @@ -601,7 +601,7 @@ private boolean evaluatePrerequisiteFlagCondition(PrerequisiteFlagCondition prer (settingType == SettingType.STRING && prerequisiteFlagCondition.getValue().getStringValue() == null) || (settingType == SettingType.INT && prerequisiteFlagCondition.getValue().getIntegerValue() == null) || (settingType == SettingType.DOUBLE && prerequisiteFlagCondition.getValue().getDoubleValue() == null)) { - throw new IllegalArgumentException("Type mismatch between comparison value '" + prerequisiteFlagCondition.getValue() + "' and prerequisite flag '" + prerequisiteFlagKey + "'."); + throw new InvalidConfigModelException( "Type mismatch between comparison value '" + prerequisiteFlagCondition.getValue() + "' and prerequisite flag '" + prerequisiteFlagKey + "'."); } List visitedKeys = context.getVisitedKeys(); @@ -611,7 +611,7 @@ private boolean evaluatePrerequisiteFlagCondition(PrerequisiteFlagCondition prer visitedKeys.add(context.getKey()); if (visitedKeys.contains(prerequisiteFlagKey)) { String dependencyCycle = EvaluateLogger.formatCircularDependencyList(visitedKeys, prerequisiteFlagKey); - throw new IllegalArgumentException("Circular dependency detected between the following depending flags: " + dependencyCycle + "."); + throw new InvalidConfigModelException( "Circular dependency detected between the following depending flags: " + dependencyCycle + "."); } evaluateLogger.logPrerequisiteFlagEvaluationStart(prerequisiteFlagKey); @@ -633,7 +633,7 @@ private boolean evaluatePrerequisiteFlagCondition(PrerequisiteFlagCondition prer boolean result; if (prerequisiteComparator == null) { - throw new IllegalArgumentException("Prerequisite Flag comparison operator is invalid."); + throw new InvalidConfigModelException( "Prerequisite Flag comparison operator is invalid."); } switch (prerequisiteComparator) { case EQUALS: @@ -643,7 +643,7 @@ private boolean evaluatePrerequisiteFlagCondition(PrerequisiteFlagCondition prer result = !conditionValue.equalsBasedOnSettingType(evaluateResult.value, prerequisiteFlagSetting.getType()); break; default: - throw new IllegalArgumentException("Prerequisite Flag comparison operator is invalid."); + throw new InvalidConfigModelException( "Prerequisite Flag comparison operator is invalid."); } evaluateLogger.logPrerequisiteFlagEvaluationResult(prerequisiteFlagCondition, evaluateResult.value, result); @@ -695,19 +695,19 @@ private EvaluationResult evaluatePercentageOptions(PercentageOption[] percentage } } - throw new IllegalArgumentException("Sum of percentage option percentages is less than 100."); + throw new InvalidConfigModelException( "Sum of percentage option percentages is less than 100."); } private static T ensureComparisonValue(T value) { if (value == null) { - throw new IllegalArgumentException(COMPARISON_VALUE_IS_MISSING_OR_INVALID); + throw new InvalidConfigModelException( COMPARISON_VALUE_IS_MISSING_OR_INVALID); } return value; } private static String ensureConfigSalt(String configSalt){ if(configSalt == null){ - throw new IllegalArgumentException("Config JSON salt is missing."); + throw new InvalidConfigModelException( "Config JSON salt is missing."); } return configSalt; } @@ -717,7 +717,7 @@ private void validateSettingValueType(SettingValue settingValue, SettingType set || (SettingType.INT.equals(settingType) && settingValue.getIntegerValue() == null ) || (SettingType.DOUBLE.equals(settingType) && settingValue.getDoubleValue() == null) || (SettingType.BOOLEAN.equals(settingType) && settingValue.getBooleanValue() == null)) { - throw new IllegalArgumentException("Setting value is not of the expected type " + settingType.name() + "."); + throw new InvalidConfigModelException( "Setting value is not of the expected type " + settingType.name() + "."); } } } @@ -728,3 +728,9 @@ public RolloutEvaluatorException(String message) { } } +class InvalidConfigModelException extends IllegalArgumentException { + public InvalidConfigModelException(String message) { + super(message); + } +} + diff --git a/src/main/java/com/configcat/Utils.java b/src/main/java/com/configcat/Utils.java index a118b0a..e3dd53f 100644 --- a/src/main/java/com/configcat/Utils.java +++ b/src/main/java/com/configcat/Utils.java @@ -67,10 +67,12 @@ private Constants() { /* prevent from instantiation*/ } final class Result { private final T value; private final Object error; + private final EvaluationErrorCode errorCode; - private Result(T value, Object error) { + private Result(T value, Object error, EvaluationErrorCode errorCode) { this.value = value; this.error = error; + this.errorCode = errorCode; } T value() { @@ -81,11 +83,20 @@ Object error() { return this.error; } - static Result error(Object error, T value) { - return new Result<>(value, error); + EvaluationErrorCode errorCode() {return this.errorCode;} + + static Result error(Object error, T value, EvaluationErrorCode errorCode) { + return new Result<>(value, error, errorCode); } static Result success(T value) { - return new Result<>(value, null); + return new Result<>(value, null, EvaluationErrorCode.NONE); } } + +final class EvaluationException extends IllegalArgumentException { + EvaluationException(String message) { + super(message); + } +} + diff --git a/src/test/java/com/configcat/ConfigCatClientTest.java b/src/test/java/com/configcat/ConfigCatClientTest.java index aa8d54d..450f019 100644 --- a/src/test/java/com/configcat/ConfigCatClientTest.java +++ b/src/test/java/com/configcat/ConfigCatClientTest.java @@ -970,6 +970,27 @@ void testGetValueInvalidTypes(String settingKey, Class callType, Object defaultV cl.close(); } + @ParameterizedTest + @MethodSource("testGetValueInvalidTypesData") + void testGetValueDetailsInvalidTypes(String settingKey, Class callType, Object defaultValue) throws IOException { + MockWebServer server = new MockWebServer(); + server.start(); + + server.enqueue(new MockResponse().setResponseCode(200).setBody(TEST_JSON_TYPES)); + + ConfigCatClient cl = ConfigCatClient.get(Helpers.SDK_KEY, options -> { + options.pollingMode(PollingModes.lazyLoad()); + options.baseUrl(server.url("/").toString()); + }); + + EvaluationDetails result = cl.getValueDetails(callType, settingKey, defaultValue); + assertEquals(EvaluationErrorCode.SETTING_VALUE_TYPE_MISMATCH, result.getErrorCode()); + assertEquals("Only String, Integer, Double or Boolean types are supported.", result.getError()); + + server.shutdown(); + cl.close(); + } + @Test void testSpecialCharactersWorks() throws IOException { ClassLoader classLoader = getClass().getClassLoader(); From d9aa2fdebc83d14d6e293dffb7c97cd0c1a4dce8 Mon Sep 17 00:00:00 2001 From: novalisdenahi Date: Mon, 15 Jun 2026 15:07:25 +0200 Subject: [PATCH 2/7] WIP Enhance error reporting with detailed evaluation and refresh error codes --- .../java/com/configcat/ConfigCatClient.java | 8 +++--- .../java/com/configcat/ConfigFetcher.java | 6 ++--- .../java/com/configcat/ConfigService.java | 13 +++++----- .../com/configcat/EvaluationErrorCode.java | 7 +++++- .../java/com/configcat/RefreshErrorCode.java | 7 +++++- src/main/java/com/configcat/Utils.java | 25 +++++++++++++------ 6 files changed, 44 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/configcat/ConfigCatClient.java b/src/main/java/com/configcat/ConfigCatClient.java index 055c884..398e570 100644 --- a/src/main/java/com/configcat/ConfigCatClient.java +++ b/src/main/java/com/configcat/ConfigCatClient.java @@ -149,7 +149,7 @@ public CompletableFuture> getValueDetailsAsync(Class return this.getSettingsAsync() .thenApply(settingsResult -> { - Result checkSettingResult = checkSettingAvailable(settingsResult, key, defaultValue); + Result checkSettingResult = checkSettingAvailable(settingsResult, key, defaultValue); if (checkSettingResult.error() != null) { EvaluationDetails evaluationDetails = EvaluationDetails.fromError(key, defaultValue, checkSettingResult.errorCode(), checkSettingResult.error(), user); this.hooks.invokeOnFlagEvaluated(evaluationDetails); @@ -455,7 +455,7 @@ private CompletableFuture getSettingsAsync() { private T getValueFromSettingsMap(Class classOfT, SettingResult settingResult, String key, User user, T defaultValue) { User userObject = user != null ? user : this.defaultUser; try { - Result checkSettingResult = checkSettingAvailable(settingResult, key, defaultValue); + Result checkSettingResult = checkSettingAvailable(settingResult, key, defaultValue); if (checkSettingResult.error() != null) { this.hooks.invokeOnFlagEvaluated(EvaluationDetails.fromError(key, defaultValue, checkSettingResult.errorCode(), checkSettingResult.error(), user)); return defaultValue; @@ -574,7 +574,7 @@ private boolean checkSettingsAvailable(SettingResult settingResult, String empty return true; } - private Result checkSettingAvailable(SettingResult settingResult, String key, T defaultValue) { + private Result checkSettingAvailable(SettingResult settingResult, String key, T defaultValue) { if (settingResult.isEmpty()) { FormattableLogMessage errorMessage = ConfigCatLogMessages.getConfigJsonIsNotPresentedWithDefaultValue(key, "defaultValue", defaultValue); this.logger.error(1000, errorMessage); @@ -589,7 +589,7 @@ private Result checkSettingAvailable(SettingResult settingResult, S return Result.error(errorMessage, null, EvaluationErrorCode.SETTING_KEY_MISSING); } - return Result.success(setting); + return Result.success(setting, EvaluationErrorCode.NONE); } /** diff --git a/src/main/java/com/configcat/ConfigFetcher.java b/src/main/java/com/configcat/ConfigFetcher.java index c0d47a5..1365206 100644 --- a/src/main/java/com/configcat/ConfigFetcher.java +++ b/src/main/java/com/configcat/ConfigFetcher.java @@ -194,7 +194,7 @@ private void callHTTP(String previousETag, CompletableFuture resu if (responseCode == 200) { String content = readBody(urlConnection.getInputStream()); String eTag = readHeaderValue(responseHeaders,"ETag"); - Result configResult = deserializeConfig(content, cfRayId); + Result configResult = deserializeConfig(content, cfRayId); if (configResult.error() != null) { fetchResponse = FetchResponse.failed(configResult.error(), RefreshErrorCode.INVALID_HTTP_RESPONSE_CONTENT, false, cfRayId); } else { @@ -275,9 +275,9 @@ private String readBody(InputStream inputStream) throws IOException { return body.toString(); } - private Result deserializeConfig(String json, String cfRayId) { + private Result deserializeConfig(String json, String cfRayId) { try { - return Result.success(Utils.deserializeConfig(json)); + return Result.success(Utils.deserializeConfig(json), EvaluationErrorCode.NONE); } catch (Exception e) { FormattableLogMessage message = ConfigCatLogMessages.getFetchReceived200WithInvalidBodyError(cfRayId); this.logger.error(1105, message, e); diff --git a/src/main/java/com/configcat/ConfigService.java b/src/main/java/com/configcat/ConfigService.java index ca7ec79..4b31974 100644 --- a/src/main/java/com/configcat/ConfigService.java +++ b/src/main/java/com/configcat/ConfigService.java @@ -42,7 +42,7 @@ class ConfigService implements Closeable { private ScheduledExecutorService pollScheduler; private String cachedEntryString = ""; private Entry cachedEntry = Entry.EMPTY; - private CompletableFuture> runningTask; + private CompletableFuture> runningTask; private final AtomicBoolean initialized = new AtomicBoolean(false); private final AtomicBoolean userIndicatedOffline; private final AtomicBoolean inForegroundAndHasNetwork; @@ -96,7 +96,7 @@ public ConfigService(String sdkKey, hooks.invokeOnClientReady(determineCacheState()); FormattableLogMessage message = ConfigCatLogMessages.getAutoPollMaxInitWaitTimeReached(autoPollingMode.getMaxInitWaitTimeSeconds()); logger.warn(4200, message); - completeRunningTask(Result.error(message, cachedEntry, null)); //TODO fix me + completeRunningTask(Result.error(message, cachedEntry, RefreshErrorCode.CLIENT_INIT_TIMED_OUT)); } } finally { lock.unlock(); @@ -137,7 +137,8 @@ public CompletableFuture refresh() { } return fetchIfOlder(Constants.DISTANT_FUTURE, false) - .thenApply(entryResult -> new RefreshResult(entryResult.error() == null, entryResult.error(), null)); //TODO fixme + .thenApply(entryResult -> new RefreshResult(entryResult.error() == null, entryResult.error(), + entryResult.error() == null ? RefreshErrorCode.NONE : entryResult.errorCode())); } public void setOnline() { @@ -167,7 +168,7 @@ private void switchStateIfNeeded() { } } - private CompletableFuture> fetchIfOlder(long threshold, boolean preferCached) { + private CompletableFuture> fetchIfOlder(long threshold, boolean preferCached) { lock.lock(); try { Entry fromCache = readCache(); @@ -216,7 +217,7 @@ private void processResponse(FetchResponse response) { writeCache(cachedEntry); } completeRunningTask(response.isFailed() - ? Result.error(response.error(), cachedEntry, null) //TODO fixme + ? Result.error(response.error(), cachedEntry, response.errorCode()) : Result.success(cachedEntry)); } setInitialized(); @@ -225,7 +226,7 @@ private void processResponse(FetchResponse response) { } } - private void completeRunningTask(Result result) { + private void completeRunningTask(Result result) { runningTask.complete(result); runningTask = null; } diff --git a/src/main/java/com/configcat/EvaluationErrorCode.java b/src/main/java/com/configcat/EvaluationErrorCode.java index 736e41c..ba593ca 100644 --- a/src/main/java/com/configcat/EvaluationErrorCode.java +++ b/src/main/java/com/configcat/EvaluationErrorCode.java @@ -3,7 +3,7 @@ /** * Specifies the possible evaluation error codes. */ -public enum EvaluationErrorCode { +public enum EvaluationErrorCode implements ErrorCode { /** An unexpected error occurred during the evaluation. */ UNEXPECTED_ERROR(-1), @@ -37,4 +37,9 @@ public enum EvaluationErrorCode { EvaluationErrorCode(int code) { this.code = code; } + + @Override + public int code() { + return this.code; + } } diff --git a/src/main/java/com/configcat/RefreshErrorCode.java b/src/main/java/com/configcat/RefreshErrorCode.java index 63f17aa..51a6b72 100644 --- a/src/main/java/com/configcat/RefreshErrorCode.java +++ b/src/main/java/com/configcat/RefreshErrorCode.java @@ -3,7 +3,7 @@ /** * Specifies the possible config data refresh error codes. */ -public enum RefreshErrorCode { +public enum RefreshErrorCode implements ErrorCode { /** An unexpected error occurred during the refresh operation. */ UNEXPECTED_ERROR(-1), @@ -46,4 +46,9 @@ public enum RefreshErrorCode { RefreshErrorCode(int code) { this.code = code; } + + @Override + public int code() { + return this.code; + } } diff --git a/src/main/java/com/configcat/Utils.java b/src/main/java/com/configcat/Utils.java index e3dd53f..b19b966 100644 --- a/src/main/java/com/configcat/Utils.java +++ b/src/main/java/com/configcat/Utils.java @@ -64,12 +64,19 @@ private Constants() { /* prevent from instantiation*/ } static final int SDK_KEY_SECTION_LENGTH = 22; } -final class Result { +/** + * Common interface for Result. + */ +interface ErrorCode { + int code(); +} + +final class Result { private final T value; private final Object error; - private final EvaluationErrorCode errorCode; + private final E errorCode; - private Result(T value, Object error, EvaluationErrorCode errorCode) { + private Result(T value, Object error, E errorCode) { this.value = value; this.error = error; this.errorCode = errorCode; @@ -83,14 +90,18 @@ Object error() { return this.error; } - EvaluationErrorCode errorCode() {return this.errorCode;} + E errorCode() {return this.errorCode;} - static Result error(Object error, T value, EvaluationErrorCode errorCode) { + static Result error(Object error, T value, E errorCode) { return new Result<>(value, error, errorCode); } - static Result success(T value) { - return new Result<>(value, null, EvaluationErrorCode.NONE); + static Result success(T value) { + return new Result<>(value, null, RefreshErrorCode.NONE); + } + + static Result success(T value, E errorCode) { + return new Result<>(value, null, errorCode); } } From 916274ee0a6f2bb844084318a647bfc237e60936 Mon Sep 17 00:00:00 2001 From: novalisdenahi Date: Wed, 1 Jul 2026 15:50:00 +0200 Subject: [PATCH 3/7] Fix test --- src/test/java/com/configcat/ConfigV2EvaluationTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/configcat/ConfigV2EvaluationTest.java b/src/test/java/com/configcat/ConfigV2EvaluationTest.java index 63f59e6..b81a340 100644 --- a/src/test/java/com/configcat/ConfigV2EvaluationTest.java +++ b/src/test/java/com/configcat/ConfigV2EvaluationTest.java @@ -154,7 +154,7 @@ public void prerequisiteFlagCircularDependencyTest(String key, String dependency }); EvaluationDetails result = client.getValueDetails(String.class, key, null, null); - assertEquals("java.lang.IllegalArgumentException: Circular dependency detected between the following depending flags: " + dependencyCycle + ".", result.getError()); + assertEquals("com.configcat.InvalidConfigModelException: Circular dependency detected between the following depending flags: " + dependencyCycle + ".", result.getError()); client.close(); } From bacb715aee72ad42268c516bb2eaa12ce330e97e Mon Sep 17 00:00:00 2001 From: novalisdenahi Date: Wed, 15 Jul 2026 17:49:21 +0200 Subject: [PATCH 4/7] Enhance test assertions for offline scenarios --- src/main/java/com/configcat/ConfigCatClient.java | 2 +- src/test/java/com/configcat/ConfigCatClientTest.java | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/configcat/ConfigCatClient.java b/src/main/java/com/configcat/ConfigCatClient.java index 398e570..d2a08c3 100644 --- a/src/main/java/com/configcat/ConfigCatClient.java +++ b/src/main/java/com/configcat/ConfigCatClient.java @@ -124,9 +124,9 @@ public EvaluationDetails getValueDetails(Class classOfT, String key, U try { return this.getValueDetailsAsync(classOfT, key, user, defaultValue).get(); } catch (InterruptedException e) { - Thread.currentThread().interrupt(); String error = "Thread interrupted."; this.logger.error(0, error, e); + Thread.currentThread().interrupt(); return EvaluationDetails.fromError(key, defaultValue, EvaluationErrorCode.UNEXPECTED_ERROR,error + ": " + e.getMessage(), user); } catch (Exception e) { this.logger.error(1002, ConfigCatLogMessages.getSettingEvaluationErrorWithDefaultValue("getValueDetails", key, "defaultValue", defaultValue), e); diff --git a/src/test/java/com/configcat/ConfigCatClientTest.java b/src/test/java/com/configcat/ConfigCatClientTest.java index 450f019..b420296 100644 --- a/src/test/java/com/configcat/ConfigCatClientTest.java +++ b/src/test/java/com/configcat/ConfigCatClientTest.java @@ -513,7 +513,11 @@ void testInitOffline() throws IOException { assertTrue(cl.isOffline()); - cl.forceRefresh(); + RefreshResult refreshResult = cl.forceRefresh(); + + assertFalse(refreshResult.isSuccess()); + assertEquals(RefreshErrorCode.OFFLINE_CLIENT, refreshResult.errorCode()); + assertEquals("Client is in offline mode, it cannot initiate HTTP calls.", refreshResult.error()); assertEquals(0, server.getRequestCount()); From c1e0bfdab5ff60f1e7775879becc59bb9aa3a1fd Mon Sep 17 00:00:00 2001 From: novalisdenahi Date: Thu, 16 Jul 2026 15:22:22 +0200 Subject: [PATCH 5/7] Add the exception object related to the error to EvaluationDetails and RefreshResult. --- .../java/com/configcat/ConfigCatClient.java | 25 +++++++++-------- .../java/com/configcat/ConfigFetcher.java | 28 +++++++++++-------- .../java/com/configcat/ConfigService.java | 13 +++++---- .../java/com/configcat/EvaluationDetails.java | 12 +++++--- .../java/com/configcat/RefreshResult.java | 8 +++++- src/main/java/com/configcat/Utils.java | 14 ++++++---- 6 files changed, 61 insertions(+), 39 deletions(-) diff --git a/src/main/java/com/configcat/ConfigCatClient.java b/src/main/java/com/configcat/ConfigCatClient.java index d2a08c3..bb27b74 100644 --- a/src/main/java/com/configcat/ConfigCatClient.java +++ b/src/main/java/com/configcat/ConfigCatClient.java @@ -127,10 +127,10 @@ public EvaluationDetails getValueDetails(Class classOfT, String key, U String error = "Thread interrupted."; this.logger.error(0, error, e); Thread.currentThread().interrupt(); - return EvaluationDetails.fromError(key, defaultValue, EvaluationErrorCode.UNEXPECTED_ERROR,error + ": " + e.getMessage(), user); + return EvaluationDetails.fromError(key, defaultValue, EvaluationErrorCode.UNEXPECTED_ERROR,error + ": " + e.getMessage(), e, user); } catch (Exception e) { this.logger.error(1002, ConfigCatLogMessages.getSettingEvaluationErrorWithDefaultValue("getValueDetails", key, "defaultValue", defaultValue), e); - return EvaluationDetails.fromError(key, defaultValue, EvaluationErrorCode.UNEXPECTED_ERROR, e.getMessage(), user); + return EvaluationDetails.fromError(key, defaultValue, EvaluationErrorCode.UNEXPECTED_ERROR, e.getMessage(), e, user); } } @@ -151,7 +151,7 @@ public CompletableFuture> getValueDetailsAsync(Class .thenApply(settingsResult -> { Result checkSettingResult = checkSettingAvailable(settingsResult, key, defaultValue); if (checkSettingResult.error() != null) { - EvaluationDetails evaluationDetails = EvaluationDetails.fromError(key, defaultValue, checkSettingResult.errorCode(), checkSettingResult.error(), user); + EvaluationDetails evaluationDetails = EvaluationDetails.fromError(key, defaultValue, checkSettingResult.errorCode(), checkSettingResult.error(), null, user); this.hooks.invokeOnFlagEvaluated(evaluationDetails); return evaluationDetails.asTypeSpecific(); } @@ -160,13 +160,13 @@ public CompletableFuture> getValueDetailsAsync(Class }); } catch (InvalidConfigModelException e) { this.logger.error(1002, ConfigCatLogMessages.getSettingEvaluationErrorWithDefaultValue("getValueDetails", key, "defaultValue", defaultValue), e); - return CompletableFuture.completedFuture(EvaluationDetails.fromError(key, defaultValue, EvaluationErrorCode.INVALID_CONFIG_MODEL, e.getMessage(), user)); + return CompletableFuture.completedFuture(EvaluationDetails.fromError(key, defaultValue, EvaluationErrorCode.INVALID_CONFIG_MODEL, e.getMessage(), null, user)); } catch (EvaluationException e) { this.logger.error(1002, ConfigCatLogMessages.getSettingEvaluationErrorWithDefaultValue("getValueDetails", key, "defaultValue", defaultValue), e); - return CompletableFuture.completedFuture(EvaluationDetails.fromError(key, defaultValue, EvaluationErrorCode.SETTING_VALUE_TYPE_MISMATCH, e.getMessage(), user)); + return CompletableFuture.completedFuture(EvaluationDetails.fromError(key, defaultValue, EvaluationErrorCode.SETTING_VALUE_TYPE_MISMATCH, e.getMessage(),null, user)); } catch (Exception e) { this.logger.error(1002, ConfigCatLogMessages.getSettingEvaluationErrorWithDefaultValue("getValueDetails", key, "defaultValue", defaultValue), e); - return CompletableFuture.completedFuture(EvaluationDetails.fromError(key, defaultValue, EvaluationErrorCode.UNEXPECTED_ERROR, e.getMessage(), user)); + return CompletableFuture.completedFuture(EvaluationDetails.fromError(key, defaultValue, EvaluationErrorCode.UNEXPECTED_ERROR, e.getMessage(), e, user)); } } @@ -339,14 +339,14 @@ public RefreshResult forceRefresh() { } catch (Exception e) { this.logger.error(1003, ConfigCatLogMessages.getForceRefreshError("forceRefresh"), e); } - return new RefreshResult(false, "An error occurred during the refresh.", RefreshErrorCode.UNEXPECTED_ERROR); + return new RefreshResult(false, "An error occurred during the refresh.", RefreshErrorCode.UNEXPECTED_ERROR, null); } @Override public CompletableFuture forceRefreshAsync() { if (configService == null) { return CompletableFuture.completedFuture(new RefreshResult(false, - "The ConfigCat SDK is in local-only mode. Calling .forceRefresh() has no effect.", RefreshErrorCode.LOCAL_ONLY_CLIENT)); + "The ConfigCat SDK is in local-only mode. Calling .forceRefresh() has no effect.", RefreshErrorCode.LOCAL_ONLY_CLIENT, null)); } return configService.refresh(); @@ -457,13 +457,13 @@ private T getValueFromSettingsMap(Class classOfT, SettingResult settingRe try { Result checkSettingResult = checkSettingAvailable(settingResult, key, defaultValue); if (checkSettingResult.error() != null) { - this.hooks.invokeOnFlagEvaluated(EvaluationDetails.fromError(key, defaultValue, checkSettingResult.errorCode(), checkSettingResult.error(), user)); + this.hooks.invokeOnFlagEvaluated(EvaluationDetails.fromError(key, defaultValue, checkSettingResult.errorCode(), checkSettingResult.error(), null, user)); return defaultValue; } return this.evaluate(classOfT, checkSettingResult.value(), key, userObject, settingResult.fetchTime(), settingResult.settings()).getValue(); } catch (Exception | NoSuchMethodError e) { FormattableLogMessage error = ConfigCatLogMessages.getSettingEvaluationFailedForOtherReason(key, "defaultValue", defaultValue); - this.hooks.invokeOnFlagEvaluated(EvaluationDetails.fromError(key, defaultValue, EvaluationErrorCode.UNEXPECTED_ERROR, error + " " + e.getMessage(), userObject)); + this.hooks.invokeOnFlagEvaluated(EvaluationDetails.fromError(key, defaultValue, EvaluationErrorCode.UNEXPECTED_ERROR, error + " " + e.getMessage(), e, userObject)); this.logger.error(2001, error, e); return defaultValue; } @@ -527,6 +527,7 @@ private EvaluationDetails evaluate(Class classOfT, Setting setting, St false, null, EvaluationErrorCode.NONE, + null, fetchTime, evaluationResult.targetingRule, evaluationResult.percentageOption); @@ -578,7 +579,7 @@ private Result checkSettingAvailable(SettingRe if (settingResult.isEmpty()) { FormattableLogMessage errorMessage = ConfigCatLogMessages.getConfigJsonIsNotPresentedWithDefaultValue(key, "defaultValue", defaultValue); this.logger.error(1000, errorMessage); - return Result.error(errorMessage, null, EvaluationErrorCode.CONFIG_JSON_NOT_AVAILABLE); + return Result.error(errorMessage, null, EvaluationErrorCode.CONFIG_JSON_NOT_AVAILABLE, null); } Map settings = settingResult.settings(); @@ -586,7 +587,7 @@ private Result checkSettingAvailable(SettingRe if (setting == null) { FormattableLogMessage errorMessage = ConfigCatLogMessages.getSettingEvaluationFailedDueToMissingKey(key, "defaultValue", defaultValue, settings.keySet()); this.logger.error(1001, errorMessage); - return Result.error(errorMessage, null, EvaluationErrorCode.SETTING_KEY_MISSING); + return Result.error(errorMessage, null, EvaluationErrorCode.SETTING_KEY_MISSING, null); } return Result.success(setting, EvaluationErrorCode.NONE); diff --git a/src/main/java/com/configcat/ConfigFetcher.java b/src/main/java/com/configcat/ConfigFetcher.java index 1365206..47972b1 100644 --- a/src/main/java/com/configcat/ConfigFetcher.java +++ b/src/main/java/com/configcat/ConfigFetcher.java @@ -23,6 +23,7 @@ public enum Status { private final Entry entry; private final Object error; private final RefreshErrorCode errorCode; + private final Throwable errorException; private final boolean fetchTimeUpdatable; private final String cfRayId; @@ -52,27 +53,30 @@ public Object error() { public RefreshErrorCode errorCode() {return this.errorCode;} + public Throwable errorException() {return this.errorException;} + public String cfRayId() {return this.cfRayId;} - FetchResponse(Status status, Entry entry, Object error, RefreshErrorCode errorCode, boolean fetchTimeUpdatable, String cfRayId) { + FetchResponse(Status status, Entry entry, Object error, RefreshErrorCode errorCode,Throwable errorException, boolean fetchTimeUpdatable, String cfRayId) { this.status = status; this.entry = entry; this.error = error; this.errorCode = errorCode; + this.errorException = errorException; this.fetchTimeUpdatable = fetchTimeUpdatable; this.cfRayId = cfRayId; } public static FetchResponse fetched(Entry entry, String cfRayId) { - return new FetchResponse(Status.FETCHED, entry == null ? Entry.EMPTY : entry, null, RefreshErrorCode.NONE, false, cfRayId); + return new FetchResponse(Status.FETCHED, entry == null ? Entry.EMPTY : entry, null, RefreshErrorCode.NONE, null, false, cfRayId); } public static FetchResponse notModified(String cfRayId) { - return new FetchResponse(Status.NOT_MODIFIED, Entry.EMPTY, null, RefreshErrorCode.NONE, true, cfRayId); + return new FetchResponse(Status.NOT_MODIFIED, Entry.EMPTY, null, RefreshErrorCode.NONE, null, true, cfRayId); } - public static FetchResponse failed(Object error, RefreshErrorCode errorCode, boolean fetchTimeUpdatable, String cfRayId) { - return new FetchResponse(Status.FAILED, Entry.EMPTY, error,errorCode, fetchTimeUpdatable, cfRayId); + public static FetchResponse failed(Object error, RefreshErrorCode errorCode, Throwable errorException, boolean fetchTimeUpdatable, String cfRayId) { + return new FetchResponse(Status.FAILED, Entry.EMPTY, error,errorCode, errorException, fetchTimeUpdatable, cfRayId); } } @@ -196,7 +200,7 @@ private void callHTTP(String previousETag, CompletableFuture resu String eTag = readHeaderValue(responseHeaders,"ETag"); Result configResult = deserializeConfig(content, cfRayId); if (configResult.error() != null) { - fetchResponse = FetchResponse.failed(configResult.error(), RefreshErrorCode.INVALID_HTTP_RESPONSE_CONTENT, false, cfRayId); + fetchResponse = FetchResponse.failed(configResult.error(), RefreshErrorCode.INVALID_HTTP_RESPONSE_CONTENT, null, false, cfRayId); } else { logger.debug("Fetch was successful: new config fetched."); fetchResponse = FetchResponse.fetched(new Entry(configResult.value(), eTag, content, System.currentTimeMillis()), cfRayId); @@ -211,24 +215,24 @@ private void callHTTP(String previousETag, CompletableFuture resu } else if (responseCode == 403 || responseCode == 404) { FormattableLogMessage message = ConfigCatLogMessages.getFetchFailedDueToInvalidSDKKey(cfRayId); logger.error(1100, message); - fetchResponse = FetchResponse.failed(message, RefreshErrorCode.INVALID_SDK_KEY, true, cfRayId); + fetchResponse = FetchResponse.failed(message, RefreshErrorCode.INVALID_SDK_KEY, null, true, cfRayId); } else { FormattableLogMessage message = ConfigCatLogMessages.getFetchFailedDueToUnexpectedHttpResponse(responseCode, urlConnection.getResponseMessage(), cfRayId); logger.error(1101, message); - fetchResponse = FetchResponse.failed(message, RefreshErrorCode.UNEXPECTED_HTTP_RESPONSE, false, cfRayId); + fetchResponse = FetchResponse.failed(message, RefreshErrorCode.UNEXPECTED_HTTP_RESPONSE, null,false, cfRayId); } } catch (SocketTimeoutException e) { FormattableLogMessage message = ConfigCatLogMessages.getFetchFailedDueToRequestTimeout(httpOptions.getConnectTimeoutMillis(), httpOptions.getReadTimeoutMillis(), cfRayId); logger.error(1102, message, e); - fetchResponse = FetchResponse.failed(message, RefreshErrorCode.HTTP_REQUEST_TIMEOUT, false, cfRayId); + fetchResponse = FetchResponse.failed(message, RefreshErrorCode.HTTP_REQUEST_TIMEOUT, e, false, cfRayId); } catch (Exception e) { FormattableLogMessage message = ConfigCatLogMessages.getFetchFailedDueToUnexpectedError(cfRayId); logger.error(1103, message, e); - fetchResponse = FetchResponse.failed(message, RefreshErrorCode.HTTP_REQUEST_FAILURE, false, cfRayId); + fetchResponse = FetchResponse.failed(message, RefreshErrorCode.HTTP_REQUEST_FAILURE, e, false, cfRayId); } finally { if(fetchResponse == null) { - fetchResponse = FetchResponse.failed(ConfigCatLogMessages.getFetchFailedDueToUnexpectedError(cfRayId), RefreshErrorCode.UNEXPECTED_ERROR, false, cfRayId); + fetchResponse = FetchResponse.failed(ConfigCatLogMessages.getFetchFailedDueToUnexpectedError(cfRayId), RefreshErrorCode.UNEXPECTED_ERROR, null, false, cfRayId); } result.complete(fetchResponse); if (urlConnection != null) { @@ -281,7 +285,7 @@ private Result deserializeConfig(String json, Strin } catch (Exception e) { FormattableLogMessage message = ConfigCatLogMessages.getFetchReceived200WithInvalidBodyError(cfRayId); this.logger.error(1105, message, e); - return Result.error(message, null, EvaluationErrorCode.INVALID_CONFIG_MODEL); + return Result.error(message, null, EvaluationErrorCode.INVALID_CONFIG_MODEL, null); } } } diff --git a/src/main/java/com/configcat/ConfigService.java b/src/main/java/com/configcat/ConfigService.java index 4b31974..b554ea3 100644 --- a/src/main/java/com/configcat/ConfigService.java +++ b/src/main/java/com/configcat/ConfigService.java @@ -96,7 +96,7 @@ public ConfigService(String sdkKey, hooks.invokeOnClientReady(determineCacheState()); FormattableLogMessage message = ConfigCatLogMessages.getAutoPollMaxInitWaitTimeReached(autoPollingMode.getMaxInitWaitTimeSeconds()); logger.warn(4200, message); - completeRunningTask(Result.error(message, cachedEntry, RefreshErrorCode.CLIENT_INIT_TIMED_OUT)); + completeRunningTask(Result.error(message, cachedEntry, RefreshErrorCode.CLIENT_INIT_TIMED_OUT, null)); } } finally { lock.unlock(); @@ -133,12 +133,15 @@ public CompletableFuture refresh() { if (isOffline()) { String offlineWarning = ConfigCatLogMessages.CONFIG_SERVICE_CANNOT_INITIATE_HTTP_CALLS_WARN; logger.warn(3200, offlineWarning); - return CompletableFuture.completedFuture(new RefreshResult(false, offlineWarning, RefreshErrorCode.OFFLINE_CLIENT)); + return CompletableFuture.completedFuture(new RefreshResult(false, offlineWarning, RefreshErrorCode.OFFLINE_CLIENT, null)); } return fetchIfOlder(Constants.DISTANT_FUTURE, false) - .thenApply(entryResult -> new RefreshResult(entryResult.error() == null, entryResult.error(), - entryResult.error() == null ? RefreshErrorCode.NONE : entryResult.errorCode())); + .thenApply(entryResult -> { + boolean isSucceed = entryResult.error() == null; + return new RefreshResult(isSucceed, entryResult.error(), + isSucceed ? RefreshErrorCode.NONE : entryResult.errorCode(), entryResult.errorException()); + }); } public void setOnline() { @@ -217,7 +220,7 @@ private void processResponse(FetchResponse response) { writeCache(cachedEntry); } completeRunningTask(response.isFailed() - ? Result.error(response.error(), cachedEntry, response.errorCode()) + ? Result.error(response.error(), cachedEntry, response.errorCode(), response.errorException()) : Result.success(cachedEntry)); } setInitialized(); diff --git a/src/main/java/com/configcat/EvaluationDetails.java b/src/main/java/com/configcat/EvaluationDetails.java index 54c0cd6..7d81ebb 100644 --- a/src/main/java/com/configcat/EvaluationDetails.java +++ b/src/main/java/com/configcat/EvaluationDetails.java @@ -14,6 +14,7 @@ public class EvaluationDetails { private final long fetchTimeUnixMilliseconds; private final TargetingRule matchedTargetingRule; private final PercentageOption matchedPercentageOption; + private final Throwable errorException; public EvaluationDetails(T value, String key, @@ -22,6 +23,7 @@ public EvaluationDetails(T value, boolean isDefaultValue, Object error, EvaluationErrorCode errorCode, + Throwable errorException, long fetchTimeUnixMilliseconds, TargetingRule matchedTargetingRule, PercentageOption matchedPercentageOption) { @@ -30,19 +32,21 @@ public EvaluationDetails(T value, this.variationId = variationId; this.user = user; this.isDefaultValue = isDefaultValue; - this.errorCode = errorCode; this.error = error; + this.errorCode = errorCode; + this.errorException = errorException; this.fetchTimeUnixMilliseconds = fetchTimeUnixMilliseconds; this.matchedTargetingRule = matchedTargetingRule; this.matchedPercentageOption = matchedPercentageOption; + } - static EvaluationDetails fromError(String key, T defaultValue, EvaluationErrorCode errorCode, Object error, User user) { - return new EvaluationDetails<>(defaultValue, key, "", user, true, error, errorCode, Constants.DISTANT_PAST, null, null); + static EvaluationDetails fromError(String key, T defaultValue, EvaluationErrorCode errorCode, Object error, Throwable errorException, User user) { + return new EvaluationDetails<>(defaultValue, key, "", user, true, error, errorCode, errorException, Constants.DISTANT_PAST, null, null); } EvaluationDetails asTypeSpecific() { - return new EvaluationDetails<>((TR) value, key, variationId, user, isDefaultValue, error, errorCode, fetchTimeUnixMilliseconds, matchedTargetingRule, matchedPercentageOption); + return new EvaluationDetails<>((TR) value, key, variationId, user, isDefaultValue, error, errorCode, errorException, fetchTimeUnixMilliseconds, matchedTargetingRule, matchedPercentageOption); } /** diff --git a/src/main/java/com/configcat/RefreshResult.java b/src/main/java/com/configcat/RefreshResult.java index 78e41c1..2c8f273 100644 --- a/src/main/java/com/configcat/RefreshResult.java +++ b/src/main/java/com/configcat/RefreshResult.java @@ -9,11 +9,13 @@ public class RefreshResult { private final boolean success; private final Object error; private final RefreshErrorCode errorCode; + private final Throwable errorException; - RefreshResult(boolean success, Object error, RefreshErrorCode errorCode) { + RefreshResult(boolean success, Object error, RefreshErrorCode errorCode, Throwable errorException) { this.success = success; this.error = error; this.errorCode = errorCode; + this.errorException = errorException; } public boolean isSuccess() { @@ -30,4 +32,8 @@ public String error() { public RefreshErrorCode errorCode() { return errorCode; } + + public Throwable errorException() { + return errorException; + } } diff --git a/src/main/java/com/configcat/Utils.java b/src/main/java/com/configcat/Utils.java index b19b966..b36bbca 100644 --- a/src/main/java/com/configcat/Utils.java +++ b/src/main/java/com/configcat/Utils.java @@ -75,11 +75,13 @@ final class Result { private final T value; private final Object error; private final E errorCode; + private final Throwable errorException; - private Result(T value, Object error, E errorCode) { + private Result(T value, Object error, E errorCode, Throwable errorException) { this.value = value; this.error = error; this.errorCode = errorCode; + this.errorException = errorException; } T value() { @@ -92,16 +94,18 @@ Object error() { E errorCode() {return this.errorCode;} - static Result error(Object error, T value, E errorCode) { - return new Result<>(value, error, errorCode); + Throwable errorException() {return this.errorException;} + + static Result error(Object error, T value, E errorCode, Throwable errorException) { + return new Result<>(value, error, errorCode, errorException); } static Result success(T value) { - return new Result<>(value, null, RefreshErrorCode.NONE); + return new Result<>(value, null, RefreshErrorCode.NONE, null); } static Result success(T value, E errorCode) { - return new Result<>(value, null, errorCode); + return new Result<>(value, null, errorCode, null); } } From 7bf58124cc8d50f2561067670fc765d6764c12b0 Mon Sep 17 00:00:00 2001 From: novalisdenahi Date: Fri, 17 Jul 2026 14:34:03 +0200 Subject: [PATCH 6/7] Update tests and add missing getErrorException to EvaluationDetails --- .../java/com/configcat/EvaluationDetails.java | 7 +++ .../com/configcat/ConfigCatClientTest.java | 59 ++++++++++++++----- .../java/com/configcat/ConfigFetcherTest.java | 22 ++++++- .../java/com/configcat/VariationIdTests.java | 9 +++ 4 files changed, 80 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/configcat/EvaluationDetails.java b/src/main/java/com/configcat/EvaluationDetails.java index 7d81ebb..346e5cd 100644 --- a/src/main/java/com/configcat/EvaluationDetails.java +++ b/src/main/java/com/configcat/EvaluationDetails.java @@ -101,6 +101,13 @@ public EvaluationErrorCode getErrorCode() { return errorCode; } + /** + * The error exception object related to the error. If the evaluation was successful, this will be null. + */ + public Throwable getErrorException() { + return errorException; + } + /** * The last fetch time of the config.json in unix milliseconds format. */ diff --git a/src/test/java/com/configcat/ConfigCatClientTest.java b/src/test/java/com/configcat/ConfigCatClientTest.java index b420296..e1be02c 100644 --- a/src/test/java/com/configcat/ConfigCatClientTest.java +++ b/src/test/java/com/configcat/ConfigCatClientTest.java @@ -1,6 +1,7 @@ package com.configcat; import java9.util.concurrent.CompletableFuture; +import kotlin.jvm.internal.Ref; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import org.junit.jupiter.api.Test; @@ -194,9 +195,12 @@ void getConfigurationReturnsPreviousCachedOnTimeout() throws IOException { server.enqueue(new MockResponse().setResponseCode(200).setBody(TEST_JSON)); server.enqueue(new MockResponse().setResponseCode(200).setBody("delayed").setBodyDelay(3, TimeUnit.SECONDS)); - cl.forceRefresh(); + RefreshResult result = cl.forceRefresh(); + assertEquals(RefreshErrorCode.NONE, result.errorCode()); assertEquals("fakeValue", cl.getValue(String.class, "fakeKey", null)); - cl.forceRefresh(); + result = cl.forceRefresh(); + assertEquals(RefreshErrorCode.HTTP_REQUEST_TIMEOUT, result.errorCode()); + assertEquals("Read timed out", result.errorException().getMessage()); assertEquals("fakeValue", cl.getValue(String.class, "fakeKey", null)); server.close(); @@ -237,9 +241,12 @@ void getConfigurationReturnsPreviousCachedOnFailAsync() throws IOException, Exec server.enqueue(new MockResponse().setResponseCode(200).setBody(TEST_JSON)); server.enqueue(new MockResponse().setResponseCode(500)); - cl.forceRefresh(); + RefreshResult result = cl.forceRefresh(); + assertEquals(RefreshErrorCode.NONE, result.errorCode()); assertEquals("fakeValue", cl.getValueAsync(String.class, "fakeKey", null).get()); - cl.forceRefresh(); + result = cl.forceRefresh(); + assertEquals(RefreshErrorCode.UNEXPECTED_HTTP_RESPONSE, result.errorCode()); + assertNull(result.errorException()); assertEquals("fakeValue", cl.getValueAsync(String.class, "fakeKey", null).get()); server.close(); @@ -262,10 +269,14 @@ void getValueReturnsDefaultOnExceptionRepeatedly() throws IOException { server.enqueue(new MockResponse().setResponseCode(200).setBody(badJson)); server.enqueue(new MockResponse().setResponseCode(200).setBody(badJson).setBodyDelay(3, TimeUnit.SECONDS)); - cl.forceRefresh(); + RefreshResult result = cl.forceRefresh(); + assertEquals(RefreshErrorCode.INVALID_HTTP_RESPONSE_CONTENT, result.errorCode()); + assertNull(result.errorException()); assertSame(def, cl.getValue(String.class, "test", def)); - cl.forceRefresh(); + result = cl.forceRefresh(); + assertEquals(RefreshErrorCode.HTTP_REQUEST_TIMEOUT, result.errorCode()); + assertEquals("Read timed out", result.errorException().getMessage()); assertSame(def, cl.getValue(String.class, "test", def)); server.shutdown(); @@ -285,7 +296,9 @@ void forceRefreshWithTimeout() throws IOException { server.enqueue(new MockResponse().setResponseCode(200).setBody("test").setBodyDelay(3, TimeUnit.SECONDS)); - cl.forceRefresh(); + RefreshResult result = cl.forceRefresh(); + assertEquals(RefreshErrorCode.HTTP_REQUEST_TIMEOUT, result.errorCode()); + assertEquals("Read timed out", result.errorException().getMessage()); server.shutdown(); cl.close(); @@ -358,7 +371,9 @@ void testAutoPollRefreshFail() throws IOException { options.baseUrl(server.url("/").toString()); }); - cl.forceRefresh(); + RefreshResult result = cl.forceRefresh(); + assertEquals(RefreshErrorCode.UNEXPECTED_HTTP_RESPONSE, result.errorCode()); + assertNull(result.errorException()); assertEquals("", cl.getValue(String.class, "fakeKey", "")); server.close(); @@ -377,7 +392,9 @@ void testLazyRefreshFail() throws IOException { options.baseUrl(server.url("/").toString()); }); - cl.forceRefresh(); + RefreshResult result = cl.forceRefresh(); + assertEquals(RefreshErrorCode.UNEXPECTED_HTTP_RESPONSE, result.errorCode()); + assertNull(result.errorException()); assertEquals("", cl.getValue(String.class, "fakeKey", "")); server.close(); @@ -396,7 +413,9 @@ void testManualPollRefreshFail() throws IOException { options.baseUrl(server.url("/").toString()); }); - cl.forceRefresh(); + RefreshResult result = cl.forceRefresh(); + assertEquals(RefreshErrorCode.UNEXPECTED_HTTP_RESPONSE, result.errorCode()); + assertNull(result.errorException()); assertEquals("", cl.getValue(String.class, "fakeKey", "")); server.close(); @@ -478,19 +497,25 @@ void testOnlineOffline() throws IOException { assertFalse(cl.isOffline()); - cl.forceRefresh(); + RefreshResult result = cl.forceRefresh(); + assertEquals(RefreshErrorCode.NONE, result.errorCode()); + assertNull(result.errorException()); assertEquals(1, server.getRequestCount()); cl.setOffline(); assertTrue(cl.isOffline()); - cl.forceRefresh(); + result = cl.forceRefresh(); + assertEquals(RefreshErrorCode.OFFLINE_CLIENT, result.errorCode()); + assertNull(result.errorException()); assertEquals(1, server.getRequestCount()); cl.setOnline(); - cl.forceRefresh(); + result = cl.forceRefresh(); + assertEquals(RefreshErrorCode.NONE, result.errorCode()); + assertNull(result.errorException()); assertEquals(2, server.getRequestCount()); @@ -514,7 +539,6 @@ void testInitOffline() throws IOException { assertTrue(cl.isOffline()); RefreshResult refreshResult = cl.forceRefresh(); - assertFalse(refreshResult.isSuccess()); assertEquals(RefreshErrorCode.OFFLINE_CLIENT, refreshResult.errorCode()); assertEquals("Client is in offline mode, it cannot initiate HTTP calls.", refreshResult.error()); @@ -750,6 +774,8 @@ void testOnFlagEvaluationError() throws IOException { options.baseUrl(server.url("/").toString()); options.hooks().addOnFlagEvaluated(details -> { assertEquals("", details.getValue()); + assertEquals(EvaluationErrorCode.CONFIG_JSON_NOT_AVAILABLE, details.getErrorCode()); + assertNull(details.getErrorException()); assertEquals("Config JSON is not present when evaluating setting 'key'. Returning the `defaultValue` parameter that you specified in your application: ''.", details.getError()); assertTrue(details.isDefaultValue()); called.set(true); @@ -826,6 +852,7 @@ void getAllValueDetails() throws IOException { assertTrue((boolean) element.getValue()); assertFalse(element.isDefaultValue()); assertNull(element.getError()); + assertEquals(EvaluationErrorCode.NONE, element.getErrorCode()); assertEquals("fakeId1", element.getVariationId()); //assert result 2 @@ -834,6 +861,7 @@ void getAllValueDetails() throws IOException { assertFalse((boolean) element.getValue()); assertFalse(element.isDefaultValue()); assertNull(element.getError()); + assertEquals(EvaluationErrorCode.NONE, element.getErrorCode()); assertEquals("fakeId2", element.getVariationId()); server.shutdown(); cl.close(); @@ -863,6 +891,7 @@ void getAllValueDetailsAsync() throws IOException, ExecutionException, Interrupt assertTrue((boolean) element.getValue()); assertFalse(element.isDefaultValue()); assertNull(element.getError()); + assertEquals(EvaluationErrorCode.NONE, element.getErrorCode()); assertEquals("fakeId1", element.getVariationId()); //assert result 2 @@ -871,6 +900,7 @@ void getAllValueDetailsAsync() throws IOException, ExecutionException, Interrupt assertFalse((boolean) element.getValue()); assertFalse(element.isDefaultValue()); assertNull(element.getError()); + assertEquals(EvaluationErrorCode.NONE, element.getErrorCode()); assertEquals("fakeId2", element.getVariationId()); server.shutdown(); cl.close(); @@ -990,6 +1020,7 @@ void testGetValueDetailsInvalidTypes(String settingKey, Class callType, Object d EvaluationDetails result = cl.getValueDetails(callType, settingKey, defaultValue); assertEquals(EvaluationErrorCode.SETTING_VALUE_TYPE_MISMATCH, result.getErrorCode()); assertEquals("Only String, Integer, Double or Boolean types are supported.", result.getError()); + assertNull(result.getErrorException()); server.shutdown(); cl.close(); diff --git a/src/test/java/com/configcat/ConfigFetcherTest.java b/src/test/java/com/configcat/ConfigFetcherTest.java index c8cc04f..481f0a2 100644 --- a/src/test/java/com/configcat/ConfigFetcherTest.java +++ b/src/test/java/com/configcat/ConfigFetcherTest.java @@ -78,6 +78,9 @@ void fetchException() throws IOException, ExecutionException, InterruptedExcepti FetchResponse response = fetch.fetchAsync(null).get(); assertTrue(response.isFailed()); + assertEquals(RefreshErrorCode.HTTP_REQUEST_TIMEOUT, response.errorCode()); + assertEquals("Request timed out while trying to fetch config JSON. Timeout values: [connect: 10000ms, read: 1000ms]", response.error().toString()); + assertEquals("Read timed out", response.errorException().getMessage()); assertEquals(Entry.EMPTY, response.entry()); fetch.close(); @@ -135,6 +138,8 @@ void fetchSuccess() throws Exception { FetchResponse response = fetcher.fetchAsync(null).get(); assertTrue(response.isFetched()); + assertNull(response.error()); + assertEquals(RefreshErrorCode.NONE, response.errorCode()); assertEquals("fakeValue", response.entry().getConfig().getEntries().get("fakeKey").getSettingsValue().getStringValue()); fetcher.close(); @@ -161,8 +166,12 @@ void fetchEmpty(String body) throws Exception { FetchResponse response = fetcher.fetchAsync(null).get(); assertFalse(response.isFetched()); + assertTrue(response.isFailed()); + assertEquals(RefreshErrorCode.INVALID_HTTP_RESPONSE_CONTENT, response.errorCode()); + assertNull(response.errorException()); assertEquals("Fetching config JSON was successful but the HTTP response content was invalid.", response.error().toString()); + fetcher.close(); } @@ -200,6 +209,8 @@ void fetchedFail403ContainsCFRAY() throws Exception { FetchResponse response = fetcher.fetchAsync("fakeETag").get(); assertTrue(response.isFailed()); + assertEquals(RefreshErrorCode.INVALID_SDK_KEY, response.errorCode()); + assertNull(response.errorException()); assertTrue(response.error().toString().contains("(Ray ID: 12345)")); verify(mockLogger, times(1)).error(anyString(), eq(1100), eq(ConfigCatLogMessages.getFetchFailedDueToInvalidSDKKey("12345"))); @@ -226,7 +237,8 @@ void fetchedNotModified304ContainsCFRAY() throws Exception { FetchResponse response = fetcher.fetchAsync("fakeETag").get(); assertTrue(response.isNotModified()); - + assertNull(response.error()); + assertEquals(RefreshErrorCode.NONE, response.errorCode()); verify(mockLogger, times(1)).debug(anyString(), eq(0), eq(String.format("Fetch was successful: config not modified. %s", ConfigCatLogMessages.getCFRayIdPostFix("12345")))); fetcher.close(); @@ -252,7 +264,8 @@ void fetchedReceivedInvalidBodyContainsCFRAY() throws Exception { assertTrue(response.isFailed()); assertTrue(response.error().toString().contains("(Ray ID: 12345)")); - + assertEquals(RefreshErrorCode.INVALID_HTTP_RESPONSE_CONTENT, response.errorCode()); + assertNull(response.errorException()); verify(mockLogger, times(1)).error(anyString(), eq(1105), eq(ConfigCatLogMessages.getFetchReceived200WithInvalidBodyError("12345")), any(), any(Exception.class)); fetcher.close(); @@ -280,7 +293,8 @@ void fetchFailedDueToRequestTimeoutContainsCFRAY() throws Exception { assertTrue(response.isFailed()); assertTrue(response.error().toString().contains("Request timed out while trying to fetch config JSON.")); assertTrue(response.error().toString().contains("(Ray ID: timeout-ray-123)")); - + assertEquals(RefreshErrorCode.HTTP_REQUEST_TIMEOUT, response.errorCode()); + assertEquals("Read timed out", response.errorException().getMessage()); verify(mockLogger, times(1)).error(anyString(), eq(1102), eq(ConfigCatLogMessages.getFetchFailedDueToRequestTimeout(10000, 1000, "timeout-ray-123")), any(), any(Exception.class)); fetcher.close(); @@ -312,6 +326,8 @@ void fetchFailedDueToUnexpectedErrorContainsCFRAY() throws Exception { assertTrue(response.isFailed()); assertTrue(response.error().toString().contains("Unexpected error occurred while trying to fetch config JSON.")); assertTrue(response.error().toString().contains("(Ray ID: unexpected-ray-456)")); + assertEquals(RefreshErrorCode.HTTP_REQUEST_FAILURE, response.errorCode()); + assertEquals("Premature EOF", response.errorException().getMessage()); fetcher.close(); } diff --git a/src/test/java/com/configcat/VariationIdTests.java b/src/test/java/com/configcat/VariationIdTests.java index 0b2df91..4b0798c 100644 --- a/src/test/java/com/configcat/VariationIdTests.java +++ b/src/test/java/com/configcat/VariationIdTests.java @@ -42,6 +42,7 @@ void getVariationIdWorks() { server.enqueue(new MockResponse().setResponseCode(200).setBody(TEST_JSON)); EvaluationDetails valueDetails = client.getValueDetails(Boolean.class, "key1", null); assertEquals("fakeId1", valueDetails.getVariationId()); + assertEquals(EvaluationErrorCode.NONE, valueDetails.getErrorCode()); } @Test @@ -49,6 +50,7 @@ void getVariationIdAsyncWorks() throws ExecutionException, InterruptedException server.enqueue(new MockResponse().setResponseCode(200).setBody(TEST_JSON)); EvaluationDetails valueDetails = client.getValueDetailsAsync(Boolean.class, "key2", null).get(); assertEquals("fakeId2", valueDetails.getVariationId()); + assertEquals(EvaluationErrorCode.NONE, valueDetails.getErrorCode()); } @Test @@ -56,6 +58,7 @@ void getVariationIdNotFound() { server.enqueue(new MockResponse().setResponseCode(200).setBody(TEST_JSON)); EvaluationDetails valueDetails = client.getValueDetails(Boolean.class, "nonexisting", false); assertEquals("", valueDetails.getVariationId()); + assertEquals(EvaluationErrorCode.SETTING_KEY_MISSING, valueDetails.getErrorCode()); } @Test @@ -65,8 +68,11 @@ void getAllVariationIdsWorks() { List> allValueDetails = client.getAllValueDetails(null); assertEquals(3, allValueDetails.size()); assertEquals("fakeId1", allValueDetails.get(0).getVariationId()); + assertEquals(EvaluationErrorCode.NONE, allValueDetails.get(0).getErrorCode()); assertEquals("fakeId2", allValueDetails.get(1).getVariationId()); + assertEquals(EvaluationErrorCode.NONE, allValueDetails.get(1).getErrorCode()); assertEquals("fakeId3", allValueDetails.get(2).getVariationId()); + assertEquals(EvaluationErrorCode.NONE, allValueDetails.get(2).getErrorCode()); } @@ -85,8 +91,11 @@ void getAllVariationIdsAsyncWorks() throws ExecutionException, InterruptedExcept List> allValueDetails = client.getAllValueDetailsAsync(null).get(); assertEquals(3, allValueDetails.size()); assertEquals("fakeId1", allValueDetails.get(0).getVariationId()); + assertEquals(EvaluationErrorCode.NONE, allValueDetails.get(0).getErrorCode()); assertEquals("fakeId2", allValueDetails.get(1).getVariationId()); + assertEquals(EvaluationErrorCode.NONE, allValueDetails.get(1).getErrorCode()); assertEquals("fakeId3", allValueDetails.get(2).getVariationId()); + assertEquals(EvaluationErrorCode.NONE, allValueDetails.get(2).getErrorCode()); } From 67ec576efe3cf84cc1ec95a75508246f981788d1 Mon Sep 17 00:00:00 2001 From: novalisdenahi Date: Fri, 17 Jul 2026 16:00:49 +0200 Subject: [PATCH 7/7] Update Gradle to 8.9, android-gradle to 8.2.0 and mockito-inline to 5.12.0 --- gradle/libs.versions.toml | 6 +++--- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fdaa52d..56b0a58 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,9 +8,9 @@ semantic-version = "2.1.1" junit-jupiter = "5.10.0" logback = "1.5.19" mockwebserver = "4.12.0" -mockito = "4.8.0" +mockito = "5.12.0" android-retrofuture = "1.7.4" -android-gradle = "8.0.0" +android-gradle = "8.2.0" [libraries] slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j-api" } @@ -21,7 +21,7 @@ junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.re logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } logback-core = { module = "ch.qos.logback:logback-core", version.ref = "logback" } mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "mockwebserver" } -mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockito" } +mockito-inline = { module = "org.mockito:mockito-core", version.ref = "mockito" } junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit-jupiter" } junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit-jupiter" } android-retrofuture = { module = "net.sourceforge.streamsupport:android-retrofuture", version.ref = "android-retrofuture" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 87b5be7..20dfef1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Thu Aug 04 20:00:01 CEST 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME