{"record":{"id":"2ee6c5e9d2a6b0b2","repo":"pinpoint-apm/pinpoint","slug":"circular-placeholder-reference-placeholder-in","errorCode":null,"errorMessage":"Circular placeholder reference '<placeholder>' in property definitions","messagePattern":"Circular placeholder reference '<placeholder>' in property definitions","errorType":"exception","errorClass":"IllegalArgumentException","httpStatus":null,"severity":"error","filePath":"commons-config/src/main/java/com/navercorp/pinpoint/common/config/util/spring/PropertyPlaceholderHelper.java","lineNumber":137,"sourceCode":"    public String replacePlaceholders(String value, Function<String, String> placeholderResolver) {\n        Objects.requireNonNull(value, \"value\");\n\n        return parseStringValue(value, placeholderResolver, new HashSet<>());\n    }\n\n    protected String parseStringValue(\n            String strVal, Function<String, String> placeholderResolver, Set<String> visitedPlaceholders) {\n\n        StringBuilder buf = new StringBuilder(strVal);\n\n        int startIndex = strVal.indexOf(this.placeholderPrefix);\n        while (startIndex != -1) {\n            int endIndex = findPlaceholderEndIndex(buf, startIndex);\n            if (endIndex != -1) {\n                String placeholder = buf.substring(startIndex + this.placeholderPrefix.length(), endIndex);\n                String originalPlaceholder = placeholder;\n                if (!visitedPlaceholders.add(originalPlaceholder)) {\n                    throw new IllegalArgumentException(\n                            \"Circular placeholder reference '\" + originalPlaceholder + \"' in property definitions\");\n                }\n                // Recursive invocation, parsing placeholders contained in the placeholder key.\n                placeholder = parseStringValue(placeholder, placeholderResolver, visitedPlaceholders);\n                // Now obtain the value for the fully resolved key...\n                String propVal = placeholderResolver.apply(placeholder);\n                if (propVal == null && this.valueSeparator != null) {\n                    int separatorIndex = placeholder.indexOf(this.valueSeparator);\n                    if (separatorIndex != -1) {\n                        String actualPlaceholder = placeholder.substring(0, separatorIndex);\n                        String defaultValue = placeholder.substring(separatorIndex + this.valueSeparator.length());\n                        propVal = placeholderResolver.apply(actualPlaceholder);\n                        if (propVal == null) {\n                            propVal = defaultValue;\n                        }\n                    }\n                }\n                if (propVal != null) {","sourceCodeStart":119,"sourceCodeEnd":155,"githubUrl":"https://github.com/pinpoint-apm/pinpoint/blob/744c3d3075e595656abb1ae331ad2c0e4c9eb996/commons-config/src/main/java/com/navercorp/pinpoint/common/config/util/spring/PropertyPlaceholderHelper.java#L119-L155","documentation":"PropertyPlaceholderHelper.replacePlaceholders()/parseStringValue() recursively resolves ${...} placeholders in a property string. To detect infinite recursion it tracks visited placeholders; if the same placeholder name is encountered again before resolution completes (a placeholder referencing itself directly or through a chain), it throws IllegalArgumentException naming the circular reference. In ValueAnnotationProcessor the helper is created with ignoreUnresolvablePlaceholders=false, so errors propagate.","triggerScenarios":"A property key's value contains a placeholder that resolves (possibly through several hops, A -> B -> A) back to itself, e.g. properties file has a=${b}, b=${a}, and @Value(\"${a}\") is processed.","commonSituations":"Copy-pasting property chains in config files creating accidental cycles; environment-variable fallbacks that reference each other; migrating property sets between files where a key was renamed but the old reference remains, forming a loop.","solutions":["Break the cycle by making at least one property hold a literal value instead of a placeholder","Rename one of the chained keys and update its references","Inline the final value directly into the @Value annotation or the property file entry"],"exampleFix":"// before (properties)\na=${b}\nb=${a}\n// after (properties)\na=${b}\nb=literalValue","handlingStrategy":"validation","validationCode":"// Detect cycles in a properties map before processing\nMap<String,String> props = ...;\nSet<String> visiting = new HashSet<>();\nfor (String key : props.keySet()) {\n    Deque<String> stack = new ArrayDeque<>(List.of(key));\n    while (!stack.isEmpty()) {\n        String k = stack.pop();\n        if (!visiting.add(k)) throw new IllegalStateException(\"Circular placeholder: \" + k);\n        Matcher m = Pattern.compile(\"\\\\$\\\\{([^}]+)\\\\}\").matcher(String.valueOf(props.getOrDefault(k, \"\")));\n        while (m.find()) stack.push(m.group(1));\n    }\n}","typeGuard":null,"tryCatchPattern":"try {\n    String resolved = helper.replacePlaceholders(raw, resolver);\n} catch (IllegalArgumentException e) {\n    log.error(\"Placeholder cycle: {}\", e.getMessage());\n}","preventionTips":["Never let property chains reference each other back to the original key","Keep at most one level of placeholder indirection where possible","Note ValueAnnotationProcessor.getValue() silently swallows IllegalArgumentException and returns null — validate that the expected value actually resolved","Add a startup test that resolves every property key once"],"tags":["configuration","placeholders","recursion","properties"],"backgroundTag":"circular-placeholder-reference","analyzedSha":"744c3d3075e595656abb1ae331ad2c0e4c9eb996","analyzedAt":"2026-09-07T18:48:45.289Z","contentChangedAt":"2026-09-07T18:48:45.289Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}