diff --git a/src/main/java/com/configcat/ConfigCatClient.java b/src/main/java/com/configcat/ConfigCatClient.java index 9fd5a2c..f43c3ee 100644 --- a/src/main/java/com/configcat/ConfigCatClient.java +++ b/src/main/java/com/configcat/ConfigCatClient.java @@ -94,9 +94,15 @@ public T getValue(Class classOfT, String key, User user, T defaultValue) } catch (InterruptedException e) { this.logger.error(0, "Thread interrupted.", e); Thread.currentThread().interrupt(); + EvaluationDetails evaluationDetails = EvaluationDetails.fromError(key, defaultValue, + EvaluationErrorCode.UNEXPECTED_ERROR, e.getMessage(), e, user); + this.configCatHooks.invokeOnFlagEvaluated(evaluationDetails); return defaultValue; } catch (Exception e) { this.logger.error(1002, ConfigCatLogMessages.getSettingEvaluationErrorWithDefaultValue("getValue", key, "defaultValue", defaultValue.toString()), e); + EvaluationDetails evaluationDetails = EvaluationDetails.fromError(key, defaultValue, + EvaluationErrorCode.fromException(e), e.getMessage(), e, user); + this.configCatHooks.invokeOnFlagEvaluated(evaluationDetails); return defaultValue; } } @@ -135,10 +141,16 @@ 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, error + ": " + e.getMessage(), user); + EvaluationDetails evaluationDetails = EvaluationDetails.fromError(key, defaultValue, + EvaluationErrorCode.UNEXPECTED_ERROR, error + ": " + e.getMessage(), e, user); + this.configCatHooks.invokeOnFlagEvaluated(evaluationDetails); + return evaluationDetails.asTypeSpecific(); } catch (Exception e) { this.logger.error(1002, ConfigCatLogMessages.getSettingEvaluationErrorWithDefaultValue("getValueDetails", key, "defaultValue", defaultValue), e); - return EvaluationDetails.fromError(key, defaultValue, e.getMessage(), user); + EvaluationDetails evaluationDetails = EvaluationDetails.fromError(key, defaultValue, + EvaluationErrorCode.fromException(e), e.getMessage(), e, user); + this.configCatHooks.invokeOnFlagEvaluated(evaluationDetails); + return evaluationDetails.asTypeSpecific(); } } @@ -156,15 +168,25 @@ public CompletableFuture> getValueDetailsAsync(Class return this.getSettingsAsync() .thenApply(settingsResult -> { - Result checkSettingResult = checkSettingAvailable(settingsResult, key, defaultValue); - if (checkSettingResult.error() != null) { - EvaluationDetails evaluationDetails = EvaluationDetails.fromError(key, defaultValue, checkSettingResult.error(), user); + try { + Result checkSettingResult = checkSettingAvailable(settingsResult, key, defaultValue); + if (checkSettingResult.error() != null) { + EvaluationDetails evaluationDetails = EvaluationDetails.fromError(key, defaultValue, + checkSettingResult.errorCode(), checkSettingResult.error(), null, user); + this.configCatHooks.invokeOnFlagEvaluated(evaluationDetails); + return evaluationDetails.asTypeSpecific(); + } + + return this.evaluate(classOfT, checkSettingResult.value(), + key, user != null ? user : this.defaultUser, settingsResult.fetchTime(), settingsResult.settings()); + } catch (Exception e) { + this.logger.error(1002, ConfigCatLogMessages.getSettingEvaluationErrorWithDefaultValue( + "getValueDetailsAsync", key, "defaultValue", defaultValue), e); + EvaluationDetails evaluationDetails = EvaluationDetails.fromError(key, defaultValue, + EvaluationErrorCode.fromException(e), e.getMessage(), e, user); this.configCatHooks.invokeOnFlagEvaluated(evaluationDetails); return evaluationDetails.asTypeSpecific(); } - - return this.evaluate(classOfT, checkSettingResult.value(), - key, user != null ? user : this.defaultUser, settingsResult.fetchTime(), settingsResult.settings()); }); } @@ -208,8 +230,8 @@ public CompletableFuture> getAllValuesAsync(User user) { for (String key : keys) { Setting setting = settings.get(key); - SettingValue evaluated = this.rolloutEvaluator.evaluate(setting, key, getEvaluateUser(user), settings, new EvaluateLogger(this.clientLogLevel)).value; - Object value = this.parseObject(this.classBySettingType(setting.getType()), evaluated, setting.getType()); + Object value = this.evaluateObject(this.classBySettingType(setting.getType()), setting, key, + getEvaluateUser(user), settingResult.fetchTime(), settings).getValue(); result.put(key, value); } @@ -278,6 +300,8 @@ public Map.Entry getKeyAndValue(Class classOfT, String variati if (variationId == null || variationId.isEmpty()) throw new IllegalArgumentException("'variationId' cannot be null or empty."); + validateReturnType(classOfT); + try { return this.getKeyAndValueAsync(classOfT, variationId).get(); } catch (InterruptedException e) { @@ -295,6 +319,8 @@ public CompletableFuture> getKeyAndValueAsync(Class if (variationId == null || variationId.isEmpty()) throw new IllegalArgumentException("'variationId' cannot be null or empty."); + validateReturnType(classOfT); + return this.getSettingsAsync() .thenApply(settingsResult -> this.getKeyAndValueFromSettingsMap(classOfT, settingsResult, variationId)); } @@ -337,14 +363,22 @@ public RefreshResult forceRefresh() { } catch (InterruptedException e) { this.logger.error(0, "Thread interrupted.", e); Thread.currentThread().interrupt(); + return new RefreshResult(false, "An error occurred during the refresh.", + RefreshErrorCode.UNEXPECTED_ERROR, e); } catch (Exception e) { this.logger.error(1003, ConfigCatLogMessages.getForceRefreshError("forceRefresh"), e); + return new RefreshResult(false, "An error occurred during the refresh.", + RefreshErrorCode.UNEXPECTED_ERROR, e); } - return new RefreshResult(false, "An error occurred during the refresh."); } @Override public CompletableFuture forceRefreshAsync() { + if (this.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, null)); + } return this.configService.refresh(); } @@ -485,11 +519,11 @@ 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()) { Object formattableLogMessage = ConfigCatLogMessages.getConfigJsonIsNotPresentedWithDefaultValue(key, "defaultValue", defaultValue); this.logger.error(1000, formattableLogMessage); - return Result.error(formattableLogMessage, null); + return Result.error(formattableLogMessage, null, EvaluationErrorCode.CONFIG_JSON_NOT_AVAILABLE, null); } Map settings = settingResult.settings(); @@ -497,17 +531,18 @@ private Result checkSettingAvailable(SettingResult settingResult, S if (setting == null) { FormattableLogMessage formattableLogMessage = ConfigCatLogMessages.getSettingEvaluationFailedDueToMissingKey(key, "defaultValue", defaultValue, settings.keySet()); this.logger.error(1001, formattableLogMessage); - return Result.error(formattableLogMessage, null); + return Result.error(formattableLogMessage, null, EvaluationErrorCode.SETTING_KEY_MISSING, null); } - return Result.success(setting); + return Result.success(setting, EvaluationErrorCode.NONE); } private T getValueFromSettingsMap(Class classOfT, SettingResult settingResult, String key, User user, T defaultValue) { try { - Result checkSettingResult = checkSettingAvailable(settingResult, key, defaultValue); + Result checkSettingResult = checkSettingAvailable(settingResult, key, defaultValue); if (checkSettingResult.error() != null) { - this.configCatHooks.invokeOnFlagEvaluated(EvaluationDetails.fromError(key, defaultValue, checkSettingResult.error(), user)); + this.configCatHooks.invokeOnFlagEvaluated(EvaluationDetails.fromError(key, defaultValue, + checkSettingResult.errorCode(), checkSettingResult.error(), null, user)); return defaultValue; } @@ -515,7 +550,9 @@ private T getValueFromSettingsMap(Class classOfT, SettingResult settingRe } catch (Exception e) { FormattableLogMessage formattableLogMessage = ConfigCatLogMessages.getSettingEvaluationFailedForOtherReason(key, "defaultValue", defaultValue); this.logger.error(2001, formattableLogMessage, e); - this.configCatHooks.invokeOnFlagEvaluated(EvaluationDetails.fromError(key, defaultValue, formattableLogMessage + " " + e.getMessage(), user)); + this.configCatHooks.invokeOnFlagEvaluated(EvaluationDetails.fromError(key, defaultValue, + EvaluationErrorCode.fromException(e), formattableLogMessage + " " + e.getMessage(), e, + getEvaluateUser(user))); return defaultValue; } } @@ -580,7 +617,7 @@ private Object parseObject(Class classOfT, SettingValue settingValue, Setting } 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/java/#setting-type-mapping"); @@ -602,7 +639,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 InvalidConfigModelException("Only String, Integer, Double or Boolean types are supported"); } /** @@ -688,6 +725,8 @@ private EvaluationDetails evaluateObject(Class classOfT, Setting sett user, false, null, + EvaluationErrorCode.NONE, + null, fetchTime, evaluationResult.matchedTargetingRule, evaluationResult.matchedPercentageOption); diff --git a/src/main/java/com/configcat/ConfigFetcher.java b/src/main/java/com/configcat/ConfigFetcher.java index 700bd64..3cffd81 100644 --- a/src/main/java/com/configcat/ConfigFetcher.java +++ b/src/main/java/com/configcat/ConfigFetcher.java @@ -124,19 +124,22 @@ public void onFailure(@NotNull Call call, @NotNull IOException e) { logger.debug(ConfigCatLogMessages.getDebugEnabledRequestFailed(requestId)); } int logEventId = 1103; + RefreshErrorCode errorCode = RefreshErrorCode.HTTP_REQUEST_FAILURE; Object message = ConfigCatLogMessages.getFetchFailedDueToUnexpectedError(null); if (!isClosed.get()) { if (e instanceof SocketTimeoutException) { logEventId = 1102; message = ConfigCatLogMessages.getFetchFailedDueToRequestTimeout(httpClient.connectTimeoutMillis(), httpClient.readTimeoutMillis(), httpClient.writeTimeoutMillis(), null); + errorCode = RefreshErrorCode.HTTP_REQUEST_TIMEOUT; } logger.error(logEventId, message, e); } - fetchResponse = FetchResponse.failed(message, false, null, true); + fetchResponse = FetchResponse.failed(message, errorCode, e, false, null, true); } finally { if(fetchResponse == null) { FormattableLogMessage formattableLogMessage = ConfigCatLogMessages.getFetchFailedDueToUnexpectedError(null); - fetchResponse = FetchResponse.failed(formattableLogMessage,false, null, false); + fetchResponse = FetchResponse.failed(formattableLogMessage, RefreshErrorCode.UNEXPECTED_ERROR, + null, false, null, false); } future.complete(fetchResponse); } @@ -159,9 +162,10 @@ public void onResponse(@NotNull Call call, @NotNull Response response) { logger.debug(ConfigCatLogMessages.getDebugEnabledReceivedBody(requestId, content.length())); } - Result result = deserializeConfig(content, cfRayId); + Result result = deserializeConfig(content, cfRayId); if (result.error() != null) { - fetchResponse = FetchResponse.failed(result.error(), false, cfRayId, false); + fetchResponse = FetchResponse.failed(result.error(), result.errorCode(), + result.errorException(), false, cfRayId, false); } else { fetchResponse = FetchResponse.fetched(new Entry(result.value(), eTag, content, System.currentTimeMillis()), cfRayId); logger.debug("Fetch was successful: new config fetched."); @@ -175,14 +179,16 @@ public void onResponse(@NotNull Call call, @NotNull Response response) { } } else if (responseCode == 403 || responseCode == 404) { FormattableLogMessage message = ConfigCatLogMessages.getFetchFailedDueToInvalidSDKKey(cfRayId); - fetchResponse = FetchResponse.failed(message, true, cfRayId, false); + fetchResponse = FetchResponse.failed(message, RefreshErrorCode.INVALID_SDK_KEY, + null, true, cfRayId, false); logger.error(1100, message); } else { if (isDebugLoggingEnabled){ logger.debug(ConfigCatLogMessages.getDebugEnabledReceivedUnexpectedStatusCode(requestId)); } FormattableLogMessage formattableLogMessage = ConfigCatLogMessages.getFetchFailedDueToUnexpectedHttpResponse(responseCode, response.message(), cfRayId); - fetchResponse = FetchResponse.failed(formattableLogMessage, false, cfRayId, true); + fetchResponse = FetchResponse.failed(formattableLogMessage, + RefreshErrorCode.UNEXPECTED_HTTP_RESPONSE, null, false, cfRayId, true); logger.error(1101, formattableLogMessage); } } catch (SocketTimeoutException e) { @@ -190,19 +196,22 @@ public void onResponse(@NotNull Call call, @NotNull Response response) { logger.debug(ConfigCatLogMessages.getDebugEnabledRequestTimedOut(requestId)); } FormattableLogMessage formattableLogMessage = ConfigCatLogMessages.getFetchFailedDueToRequestTimeout(httpClient.connectTimeoutMillis(), httpClient.readTimeoutMillis(), httpClient.writeTimeoutMillis(), cfRayId); - fetchResponse = FetchResponse.failed(formattableLogMessage, false, cfRayId, true); + fetchResponse = FetchResponse.failed(formattableLogMessage, + RefreshErrorCode.HTTP_REQUEST_TIMEOUT, e, false, cfRayId, true); logger.error(1102, formattableLogMessage, e); } catch (Exception e) { if (isDebugLoggingEnabled) { logger.debug(ConfigCatLogMessages.getDebugEnabledRequestFailed(requestId)); } FormattableLogMessage formattableLogMessage = ConfigCatLogMessages.getFetchFailedDueToUnexpectedError(cfRayId); - fetchResponse = FetchResponse.failed(formattableLogMessage, false, cfRayId, true); + fetchResponse = FetchResponse.failed(formattableLogMessage, + RefreshErrorCode.HTTP_REQUEST_FAILURE, e, false, cfRayId, true); logger.error(1103, formattableLogMessage, e); } finally { if(fetchResponse == null) { FormattableLogMessage formattableLogMessage = ConfigCatLogMessages.getFetchFailedDueToUnexpectedError(cfRayId); - fetchResponse = FetchResponse.failed(formattableLogMessage,false, cfRayId, false); + fetchResponse = FetchResponse.failed(formattableLogMessage, + RefreshErrorCode.UNEXPECTED_ERROR, null, false, cfRayId, false); } future.complete(fetchResponse); } @@ -290,13 +299,13 @@ private String getProxyAddress() { return proxy.type() + " @ " + proxy.address(); } - 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), RefreshErrorCode.NONE); } catch (Exception e) { FormattableLogMessage message = ConfigCatLogMessages.getFetchReceived200WithInvalidBodyError(cfRayId); this.logger.error(1105, message, e); - return Result.error(message, null); + return Result.error(message, null, RefreshErrorCode.INVALID_HTTP_RESPONSE_CONTENT, e); } } } diff --git a/src/main/java/com/configcat/ConfigService.java b/src/main/java/com/configcat/ConfigService.java index 114494b..f026cab 100644 --- a/src/main/java/com/configcat/ConfigService.java +++ b/src/main/java/com/configcat/ConfigService.java @@ -22,7 +22,7 @@ public class ConfigService implements Closeable { private final PollingMode pollingMode; private ScheduledExecutorService pollScheduler; private ScheduledExecutorService initScheduler; - private CompletableFuture> runningTask; + private CompletableFuture> runningTask; private final AtomicBoolean initialized = new AtomicBoolean(false); private final AtomicBoolean closed = new AtomicBoolean(false); private final AtomicBoolean offline; @@ -58,7 +58,8 @@ public ConfigService(String sdkKey, this.configCatHooks.invokeOnClientReady(determineCacheState(cachedEntry.get())); FormattableLogMessage formattableLogMessage = ConfigCatLogMessages.getAutoPollMaxInitWaitTimeReached(autoPollingMode.getMaxInitWaitTimeSeconds()); this.logger.warn(4200, formattableLogMessage); - completeRunningTask(Result.error(formattableLogMessage, cachedEntry.get())); + completeRunningTask(Result.error(formattableLogMessage, cachedEntry.get(), + RefreshErrorCode.CLIENT_INIT_TIMED_OUT, null)); } finally { lock.unlock(); } @@ -90,10 +91,15 @@ public CompletableFuture refresh() { if (offline.get()) { 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, null)); } return fetchIfOlder(Constants.DISTANT_FUTURE, false) - .thenApply(entryResult -> new RefreshResult(entryResult.error() == null, entryResult.error())); + .thenApply(entryResult -> { + boolean success = entryResult.error() == null; + return new RefreshResult(success, entryResult.error(), + success ? RefreshErrorCode.NONE : entryResult.errorCode(), entryResult.errorException()); + }); } public CompletableFuture getSettings() { @@ -117,7 +123,7 @@ public CompletableFuture getSettings() { } - private CompletableFuture> fetchIfOlder(long threshold, boolean preferCached) { + private CompletableFuture> fetchIfOlder(long threshold, boolean preferCached) { // Sync up with the cache and use it when it's not expired. Entry fromCache = readCache(); if (!fromCache.isEmpty() && !fromCache.getETag().equals(cachedEntry.get().getETag()) && fromCache.getFetchTime() > cachedEntry.get().getFetchTime()) { @@ -127,11 +133,11 @@ private CompletableFuture> fetchIfOlder(long threshold, boolean pr // Cache isn't expired if (cachedEntry.get().getFetchTime() > threshold) { setInitialized(); - return CompletableFuture.completedFuture(Result.success(cachedEntry.get())); + return CompletableFuture.completedFuture(Result.success(cachedEntry.get(), RefreshErrorCode.NONE)); } // If we are in offline mode or the caller prefers cached values, do not initiate fetch. if (offline.get() || preferCached) { - return CompletableFuture.completedFuture(Result.success(cachedEntry.get())); + return CompletableFuture.completedFuture(Result.success(cachedEntry.get(), RefreshErrorCode.NONE)); } lock.lock(); @@ -199,15 +205,15 @@ private void processResponse(FetchResponse response) { cachedEntry.set(entry); writeCache(entry); configCatHooks.invokeOnConfigChanged(entry.getConfig().getEntries()); - completeRunningTask(Result.success(entry)); + completeRunningTask(Result.success(entry, RefreshErrorCode.NONE)); } else { if (response.isFetchTimeUpdatable()) { cachedEntry.set(previousEntry.withFetchTime(System.currentTimeMillis())); writeCache(cachedEntry.get()); } completeRunningTask(response.isFailed() - ? Result.error(response.error(), cachedEntry.get()) - : Result.success(cachedEntry.get())); + ? Result.error(response.error(), cachedEntry.get(), response.errorCode(), response.errorException()) + : Result.success(cachedEntry.get(), RefreshErrorCode.NONE)); } setInitialized(); } finally { @@ -215,7 +221,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/EvaluationDetails.java b/src/main/java/com/configcat/EvaluationDetails.java index 3609e49..b15252d 100644 --- a/src/main/java/com/configcat/EvaluationDetails.java +++ b/src/main/java/com/configcat/EvaluationDetails.java @@ -10,6 +10,8 @@ public class EvaluationDetails { private final User user; private final boolean isDefaultValue; private final Object error; + private final EvaluationErrorCode errorCode; + private final Throwable errorException; private final long fetchTimeUnixMilliseconds; private final TargetingRule matchedTargetingRule; private final PercentageOption matchedPercentageOption; @@ -20,6 +22,8 @@ public EvaluationDetails(T value, User user, boolean isDefaultValue, Object error, + EvaluationErrorCode errorCode, + Throwable errorException, long fetchTimeUnixMilliseconds, TargetingRule matchedTargetingRule, PercentageOption matchedPercentageOption) { @@ -29,17 +33,22 @@ public EvaluationDetails(T value, this.user = user; this.isDefaultValue = isDefaultValue; 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, 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, 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, fetchTimeUnixMilliseconds, matchedTargetingRule, matchedPercentageOption); + return new EvaluationDetails<>((TR) value, key, variationId, user, isDefaultValue, error, errorCode, + errorException, fetchTimeUnixMilliseconds, matchedTargetingRule, matchedPercentageOption); } /** @@ -81,12 +90,27 @@ public boolean isDefaultValue() { * In case of an error, this field contains the error message. */ public String getError() { - if(error != null) { + if (error != null) { return error.toString(); } return null; } + /** + * The code identifying the reason for the error in case the operation failed. + * If the evaluation was successful, this will be {@link EvaluationErrorCode#NONE}. + */ + public EvaluationErrorCode getErrorCode() { + return errorCode; + } + + /** + * The exception object related to the error in case the operation failed, otherwise null. + */ + public Throwable getErrorException() { + return errorException; + } + /** * 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..69ab0d5 --- /dev/null +++ b/src/main/java/com/configcat/EvaluationErrorCode.java @@ -0,0 +1,55 @@ +package com.configcat; + +/** + * Specifies the possible evaluation error codes. + */ +public enum EvaluationErrorCode implements ErrorCode { + + /** 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; + } + + @Override + public int code() { + return this.code; + } + + public static EvaluationErrorCode fromException(Throwable exception) { + if (exception instanceof InvalidConfigModelException) { + return INVALID_CONFIG_MODEL; + } + if (exception instanceof EvaluationException) { + return SETTING_VALUE_TYPE_MISMATCH; + } + return UNEXPECTED_ERROR; + } +} diff --git a/src/main/java/com/configcat/FetchResponse.java b/src/main/java/com/configcat/FetchResponse.java index 3659f76..741b0e6 100644 --- a/src/main/java/com/configcat/FetchResponse.java +++ b/src/main/java/com/configcat/FetchResponse.java @@ -10,6 +10,8 @@ public enum Status { private final Status 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; private final boolean shouldRetry; @@ -38,28 +40,39 @@ public Object error() { return error; } + public RefreshErrorCode errorCode() {return this.errorCode;} + + public Throwable errorException() {return this.errorException;} + public String cfRayId() {return this.cfRayId;} public boolean shouldRetry() {return shouldRetry;} - FetchResponse(Status status, Entry entry, Object error, boolean fetchTimeUpdatable, String cfRayId, boolean shouldRetry) { + FetchResponse(Status status, Entry entry, Object error, RefreshErrorCode errorCode, Throwable errorException, + boolean fetchTimeUpdatable, String cfRayId, boolean shouldRetry) { this.status = status; this.entry = entry; this.error = error; + this.errorCode = errorCode; + this.errorException = errorException; this.fetchTimeUpdatable = fetchTimeUpdatable; this.cfRayId = cfRayId; this.shouldRetry = shouldRetry; } public static FetchResponse fetched(Entry entry, String cfRayId) { - return new FetchResponse(Status.FETCHED, entry == null ? Entry.EMPTY : entry, null, false, cfRayId, false); + return new FetchResponse(Status.FETCHED, entry == null ? Entry.EMPTY : entry, null, + RefreshErrorCode.NONE, null, false, cfRayId, false); } public static FetchResponse notModified(String cfRayId) { - return new FetchResponse(Status.NOT_MODIFIED, Entry.EMPTY, null, true, cfRayId, false); + return new FetchResponse(Status.NOT_MODIFIED, Entry.EMPTY, null, RefreshErrorCode.NONE, null, + true, cfRayId, false); } - public static FetchResponse failed(Object error, boolean fetchTimeUpdatable, String cfRayId, boolean shouldRetry) { - return new FetchResponse(Status.FAILED, Entry.EMPTY, error, fetchTimeUpdatable, cfRayId, shouldRetry); + public static FetchResponse failed(Object error, RefreshErrorCode errorCode, Throwable errorException, + boolean fetchTimeUpdatable, String cfRayId, boolean shouldRetry) { + return new FetchResponse(Status.FAILED, Entry.EMPTY, error, errorCode, errorException, + fetchTimeUpdatable, cfRayId, shouldRetry); } } diff --git a/src/main/java/com/configcat/RefreshErrorCode.java b/src/main/java/com/configcat/RefreshErrorCode.java new file mode 100644 index 0000000..374fa4e --- /dev/null +++ b/src/main/java/com/configcat/RefreshErrorCode.java @@ -0,0 +1,54 @@ +package com.configcat; + +/** + * Specifies the possible config data refresh error codes. + */ +public enum RefreshErrorCode implements ErrorCode { + + /** 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 {@link OverrideBehaviour#LOCAL_ONLY} + * override behavior, which prevents synchronization with the external cache and making HTTP requests. + */ + LOCAL_ONLY_CLIENT(1), + + /** + * The refresh operation failed because an 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 invalid content). */ + INVALID_HTTP_RESPONSE_CONTENT(1105), + + /** The refresh operation failed because the client is in offline mode and cannot initiate HTTP requests. */ + OFFLINE_CLIENT(3200), + + /** Client initialization could not complete within the configured maximum initialization wait time. */ + CLIENT_INIT_TIMED_OUT(4200); + + public final int code; + + RefreshErrorCode(int code) { + this.code = code; + } + + @Override + public int code() { + return this.code; + } +} diff --git a/src/main/java/com/configcat/RefreshResult.java b/src/main/java/com/configcat/RefreshResult.java index 6487a5e..329652d 100644 --- a/src/main/java/com/configcat/RefreshResult.java +++ b/src/main/java/com/configcat/RefreshResult.java @@ -6,20 +6,41 @@ public class RefreshResult { private final boolean success; private final Object error; + private final RefreshErrorCode errorCode; + private final Throwable errorException; - RefreshResult(boolean success, Object error) { + RefreshResult(boolean success, Object error, RefreshErrorCode errorCode, Throwable errorException) { this.success = success; this.error = error; + this.errorCode = errorCode; + this.errorException = errorException; } public boolean isSuccess() { return success; } + /** + * Error message in case the operation failed, otherwise null. + */ public String error() { - if(error != null) { + if (error != null) { return error.toString(); } return null; } + + /** + * The code identifying the reason for the error in case the operation failed. + */ + public RefreshErrorCode errorCode() { + return errorCode; + } + + /** + * The exception object related to the error in case the operation failed, if any. + */ + public Throwable errorException() { + return errorException; + } } \ No newline at end of file diff --git a/src/main/java/com/configcat/Result.java b/src/main/java/com/configcat/Result.java index ecf905f..0524ff7 100644 --- a/src/main/java/com/configcat/Result.java +++ b/src/main/java/com/configcat/Result.java @@ -1,12 +1,20 @@ package com.configcat; -final class Result { +interface ErrorCode { + int code(); +} + +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) { + private Result(T value, Object error, E errorCode, Throwable errorException) { this.value = value; this.error = error; + this.errorCode = errorCode; + this.errorException = errorException; } T value() { @@ -17,11 +25,25 @@ Object error() { return this.error; } - static Result error(Object error, T value) { - return new Result<>(value, error); + E errorCode() { + return this.errorCode; } - static Result success(T value) { - return new Result<>(value, null); + 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, E errorCode) { + return new Result<>(value, null, errorCode, null); + } +} + +final class EvaluationException extends IllegalArgumentException { + EvaluationException(String message) { + super(message); } } diff --git a/src/main/java/com/configcat/RolloutEvaluator.java b/src/main/java/com/configcat/RolloutEvaluator.java index a0f7490..72cf213 100644 --- a/src/main/java/com/configcat/RolloutEvaluator.java +++ b/src/main/java/com/configcat/RolloutEvaluator.java @@ -78,7 +78,7 @@ private boolean evaluateUserCondition(UserCondition userCondition, EvaluationCon } if (userComparator == null) { - throw new IllegalArgumentException(COMPARISON_OPERATOR_IS_INVALID); + throw new InvalidConfigModelException(COMPARISON_OPERATOR_IS_INVALID); } switch (userComparator) { case CONTAINS_ANY_OF: @@ -150,7 +150,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); } } @@ -240,14 +240,14 @@ 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) { @@ -255,7 +255,7 @@ private boolean evaluateHashedStartOrEndsWith(UserCondition userCondition, Strin } 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(userComparator) || UserComparator.HASHED_NOT_STARTS_WITH.equals(userComparator)) { @@ -430,11 +430,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); boolean result; @@ -443,7 +443,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: @@ -453,7 +453,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); @@ -471,7 +471,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(); @@ -479,7 +479,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(); @@ -489,7 +489,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); @@ -510,7 +510,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) { @@ -521,7 +521,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); @@ -553,7 +553,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(); @@ -675,19 +675,19 @@ private EvaluationResult evaluatePercentageOptions(PercentageOption[] percentage return new EvaluationResult(rule.getValue(), rule.getVariationId(), parentTargetingRule, rule); } } - 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; } @@ -697,7 +697,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() + "."); } } } @@ -707,3 +707,9 @@ public RolloutEvaluatorException(String message) { super(message); } } + +class InvalidConfigModelException extends IllegalArgumentException { + InvalidConfigModelException(String message) { + super(message); + } +} diff --git a/src/test/java/com/configcat/ConfigCatClientTest.java b/src/test/java/com/configcat/ConfigCatClientTest.java index 8bf8a3f..462d3af 100644 --- a/src/test/java/com/configcat/ConfigCatClientTest.java +++ b/src/test/java/com/configcat/ConfigCatClientTest.java @@ -20,6 +20,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; @@ -253,9 +254,12 @@ void getConfigurationReturnsPreviousCachedOnTimeout() throws IOException { server.enqueue(new MockResponse().setResponseCode(200).setBody("delayed").setBodyDelay(3, TimeUnit.SECONDS)); 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()); + assertNotNull(result.errorException()); assertEquals("fakeValue", cl.getValue(String.class, "fakeKey", null)); server.close(); @@ -296,9 +300,12 @@ void getConfigurationReturnsPreviousCachedOnFailAsync() throws IOException, Exec server.enqueue(new MockResponse().setResponseCode(500)); 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(); @@ -322,10 +329,14 @@ void getValueReturnsDefaultOnExceptionRepeatedly() throws IOException { server.enqueue(new MockResponse().setResponseCode(200).setBody(badJson).setBodyDelay(3, TimeUnit.SECONDS)); 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()); + assertNotNull(result.errorException()); assertSame(def, cl.getValue(String.class, "test", def)); - cl.forceRefresh(); + result = cl.forceRefresh(); + assertEquals(RefreshErrorCode.HTTP_REQUEST_TIMEOUT, result.errorCode()); + assertNotNull(result.errorException()); assertSame(def, cl.getValue(String.class, "test", def)); server.shutdown(); @@ -346,7 +357,9 @@ void forceRefreshWithTimeout() throws IOException { server.enqueue(new MockResponse().setResponseCode(200).setBody("test").setBodyDelay(3, TimeUnit.SECONDS)); 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()); + assertNotNull(result.errorException()); server.shutdown(); cl.close(); @@ -356,10 +369,12 @@ void forceRefreshWithTimeout() throws IOException { void getAllValues() throws IOException { MockWebServer server = new MockWebServer(); server.start(); + AtomicInteger evaluationCount = new AtomicInteger(); ConfigCatClient cl = ConfigCatClient.get(Helpers.SDK_KEY, options -> { options.pollingMode(PollingModes.manualPoll()); options.baseUrl(server.url("/").toString()); + options.hooks().addOnFlagEvaluated(details -> evaluationCount.incrementAndGet()); }); server.enqueue(new MockResponse().setResponseCode(200).setBody(TEST_JSON_MULTIPLE)); @@ -369,6 +384,7 @@ void getAllValues() throws IOException { assertEquals(true, allValues.get("key1")); assertEquals(false, allValues.get("key2")); + assertEquals(2, evaluationCount.get()); server.shutdown(); cl.close(); @@ -420,6 +436,8 @@ void getAllValueDetails() throws IOException { assertTrue((boolean) element.getValue()); assertFalse(element.isDefaultValue()); assertNull(element.getError()); + assertEquals(EvaluationErrorCode.NONE, element.getErrorCode()); + assertNull(element.getErrorException()); assertEquals("fakeId1", element.getVariationId()); //assert result 2 @@ -428,6 +446,8 @@ void getAllValueDetails() throws IOException { assertFalse((boolean) element.getValue()); assertFalse(element.isDefaultValue()); assertNull(element.getError()); + assertEquals(EvaluationErrorCode.NONE, element.getErrorCode()); + assertNull(element.getErrorException()); assertEquals("fakeId2", element.getVariationId()); server.shutdown(); cl.close(); @@ -457,6 +477,8 @@ void getAllValueDetailsAsync() throws IOException, ExecutionException, Interrupt assertTrue((boolean) element.getValue()); assertFalse(element.isDefaultValue()); assertNull(element.getError()); + assertEquals(EvaluationErrorCode.NONE, element.getErrorCode()); + assertNull(element.getErrorException()); assertEquals("fakeId1", element.getVariationId()); //assert result 2 @@ -465,6 +487,8 @@ void getAllValueDetailsAsync() throws IOException, ExecutionException, Interrupt assertFalse((boolean) element.getValue()); assertFalse(element.isDefaultValue()); assertNull(element.getError()); + assertEquals(EvaluationErrorCode.NONE, element.getErrorCode()); + assertNull(element.getErrorException()); assertEquals("fakeId2", element.getVariationId()); server.shutdown(); cl.close(); @@ -635,7 +659,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(); @@ -655,7 +681,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(); @@ -675,7 +703,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(); @@ -757,19 +787,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()); @@ -792,7 +828,10 @@ void testInitOffline() throws IOException { assertTrue(cl.isOffline()); - cl.forceRefresh(); + RefreshResult refreshResult = cl.forceRefresh(); + assertFalse(refreshResult.isSuccess()); + assertEquals(RefreshErrorCode.OFFLINE_CLIENT, refreshResult.errorCode()); + assertNull(refreshResult.errorException()); assertEquals(0, server.getRequestCount()); @@ -899,6 +938,19 @@ void testReadyHookLocalOnly() throws IOException { cl.close(); } + @Test + void forceRefreshReturnsLocalOnlyError() throws IOException { + ConfigCatClient client = ConfigCatClient.get("local-only", options -> + options.flagOverrides(OverrideDataSourceBuilder.map(Collections.emptyMap()), OverrideBehaviour.LOCAL_ONLY)); + + RefreshResult result = client.forceRefresh(); + + assertFalse(result.isSuccess()); + assertEquals(RefreshErrorCode.LOCAL_ONLY_CLIENT, result.errorCode()); + assertNull(result.errorException()); + client.close(); + } + @Test void testHooksAutoPollSub() throws IOException { MockWebServer server = new MockWebServer(); @@ -946,6 +998,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); @@ -1091,6 +1145,31 @@ void testGetValueInvalidTypes(String settingKey, Class callType, Object defaultV cl.close(); } + @Test + void getValueDetailsReturnsTypeMismatchError() throws IOException { + MockWebServer server = new MockWebServer(); + server.start(); + server.enqueue(new MockResponse().setResponseCode(200).setBody(TEST_JSON_TYPES)); + AtomicReference> hookDetails = new AtomicReference<>(); + + ConfigCatClient client = ConfigCatClient.get(Helpers.SDK_KEY, options -> { + options.pollingMode(PollingModes.lazyLoad()); + options.baseUrl(server.url("/").toString()); + options.hooks().addOnFlagEvaluated(hookDetails::set); + }); + + EvaluationDetails result = client.getValueDetails(String.class, "fakeKeyBoolean", "default"); + + assertEquals("default", result.getValue()); + assertTrue(result.isDefaultValue()); + assertEquals(EvaluationErrorCode.SETTING_VALUE_TYPE_MISMATCH, result.getErrorCode()); + assertInstanceOf(EvaluationException.class, result.getErrorException()); + assertSame(result.getErrorException(), hookDetails.get().getErrorException()); + assertSame(result.getErrorCode(), hookDetails.get().getErrorCode()); + server.shutdown(); + client.close(); + } + @Test void testWaitForReady() throws IOException, InterruptedException, ExecutionException { MockWebServer server = new MockWebServer(); @@ -1148,4 +1227,3 @@ void getValueAfterCloseWithDefaultUserAndClearedUser() throws IOException { } - diff --git a/src/test/java/com/configcat/ConfigFetcherTest.java b/src/test/java/com/configcat/ConfigFetcherTest.java index 25aed7f..35f149d 100644 --- a/src/test/java/com/configcat/ConfigFetcherTest.java +++ b/src/test/java/com/configcat/ConfigFetcherTest.java @@ -20,6 +20,7 @@ import java.lang.reflect.Method; import java.net.InetSocketAddress; import java.net.Proxy; +import java.net.SocketTimeoutException; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; @@ -57,11 +58,15 @@ public void fetchNotModified() throws InterruptedException, ExecutionException { FetchResponse fResult = fetcher.fetchAsync(null).get(); assertEquals("fakeValue", fResult.entry().getConfig().getEntries().get("fakeKey").getSettingsValue().getStringValue()); + assertEquals(RefreshErrorCode.NONE, fResult.errorCode()); + assertNull(fResult.errorException()); assertTrue(fResult.isFetched()); assertFalse(fResult.isNotModified()); assertFalse(fResult.isFailed()); FetchResponse notModifiedResponse = fetcher.fetchAsync(fResult.entry().getETag()).get(); + assertEquals(RefreshErrorCode.NONE, notModifiedResponse.errorCode()); + assertNull(notModifiedResponse.errorException()); assertTrue(notModifiedResponse.isNotModified()); assertFalse(notModifiedResponse.isFailed()); assertFalse(notModifiedResponse.isFetched()); @@ -86,6 +91,8 @@ public void fetchException() throws IOException, ExecutionException, Interrupted this.server.enqueue(new MockResponse().setBody("test").setBodyDelay(2, TimeUnit.SECONDS)); FetchResponse response = fetch.fetchAsync(null).get(); assertTrue(response.isFailed()); + assertEquals(RefreshErrorCode.HTTP_REQUEST_TIMEOUT, response.errorCode()); + assertInstanceOf(SocketTimeoutException.class, response.errorException()); assertTrue(response.entry().isEmpty()); assertTrue(response.entry().getConfig().isEmpty()); @@ -109,6 +116,8 @@ public void fetchTimeOutExceptionContainsCFRayIdIfPresented() throws IOException FetchResponse response = fetch.fetchAsync(null).get(); assertTrue(response.isFailed()); + assertEquals(RefreshErrorCode.HTTP_REQUEST_TIMEOUT, response.errorCode()); + assertInstanceOf(SocketTimeoutException.class, response.errorException()); assertTrue(response.entry().isEmpty()); assertTrue(response.entry().getConfig().isEmpty()); @@ -148,6 +157,8 @@ public void fetchUnexpectedErrorExceptionContainsCFRayIdIfPresented() throws IOE FetchResponse response = fetch.fetchAsync(null).get(); assertTrue(response.isFailed()); + assertEquals(RefreshErrorCode.HTTP_REQUEST_FAILURE, response.errorCode()); + assertNotNull(response.errorException()); assertTrue(response.entry().isEmpty()); assertTrue(response.entry().getConfig().isEmpty()); @@ -227,6 +238,8 @@ public void fetchSuccess() throws Exception { FetchResponse response = fetcher.fetchAsync(null).get(); assertTrue(response.isFetched()); + assertEquals(RefreshErrorCode.NONE, response.errorCode()); + assertNull(response.errorException()); assertEquals("fakeValue", response.entry().getConfig().getEntries().get("fakeKey").getSettingsValue().getStringValue()); fetcher.close(); @@ -253,6 +266,8 @@ public void fetchEmpty(String body) throws Exception { FetchResponse response = fetcher.fetchAsync(null).get(); assertFalse(response.isFetched()); + assertEquals(RefreshErrorCode.INVALID_HTTP_RESPONSE_CONTENT, response.errorCode()); + assertNotNull(response.errorException()); assertEquals("Fetching config JSON was successful but the HTTP response content was invalid.", response.error().toString()); fetcher.close(); @@ -295,6 +310,8 @@ public 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"))); @@ -321,6 +338,8 @@ public void fetchedNotModified304ContainsCFRAY() throws Exception { FetchResponse response = fetcher.fetchAsync("fakeETag").get(); assertTrue(response.isNotModified()); + assertEquals(RefreshErrorCode.NONE, response.errorCode()); + assertNull(response.errorException()); verify(mockLogger, times(1)).debug(anyString(), eq(0), eq(String.format("Fetch was successful: config not modified. %s", ConfigCatLogMessages.getCFRayIdPostFix("12345")))); @@ -346,6 +365,8 @@ public void fetchedReceivedInvalidBodyContainsCFRAY() throws Exception { FetchResponse response = fetcher.fetchAsync("fakeETag").get(); assertTrue(response.isFailed()); + assertEquals(RefreshErrorCode.INVALID_HTTP_RESPONSE_CONTENT, response.errorCode()); + assertNotNull(response.errorException()); assertTrue(response.error().toString().contains("(Ray ID: 12345)")); verify(mockLogger, times(1)).error(anyString(), eq(1105), eq(ConfigCatLogMessages.getFetchReceived200WithInvalidBodyError("12345")), any(Exception.class)); @@ -417,6 +438,8 @@ public void retryOnTransientHttpErrorBothFail() throws Exception { FetchResponse response = fetcher.fetchAsync(null).get(); assertTrue(response.isFailed()); + assertEquals(RefreshErrorCode.UNEXPECTED_HTTP_RESPONSE, response.errorCode()); + assertNull(response.errorException()); assertEquals(2, this.server.getRequestCount()); fetcher.close(); diff --git a/src/test/java/com/configcat/ConfigV2EvaluationTest.java b/src/test/java/com/configcat/ConfigV2EvaluationTest.java index 458edaa..63f816d 100644 --- a/src/test/java/com/configcat/ConfigV2EvaluationTest.java +++ b/src/test/java/com/configcat/ConfigV2EvaluationTest.java @@ -169,7 +169,9 @@ 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("Circular dependency detected between the following depending flags: " + dependencyCycle + ".", result.getError()); + assertEquals(EvaluationErrorCode.INVALID_CONFIG_MODEL, result.getErrorCode()); + assertInstanceOf(InvalidConfigModelException.class, result.getErrorException()); client.close(); } diff --git a/src/test/java/com/configcat/VariationIdTests.java b/src/test/java/com/configcat/VariationIdTests.java index 3e0fa47..13bd666 100644 --- a/src/test/java/com/configcat/VariationIdTests.java +++ b/src/test/java/com/configcat/VariationIdTests.java @@ -42,6 +42,7 @@ public 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 @@ public 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 @@ -58,8 +60,11 @@ public 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()); } @Test