fix(sentinel): SentinelFeignClientProperties#copy() should fail fast on Jackson errors - #4329
Conversation
… Jackson errors The copy() method serializes/deserializes via Jackson to produce a deep backup used by CircuitBreakerRuleChangeListener for change detection in afterSingletonsInstantiated() and updateBackup(). On any serialization failure the previous catch (Exception ignored) silently returned a brand-new SentinelFeignClientProperties() with default values, dropping every user-configured rule. The listener then compared the live properties against that empty backup and treated every existing rule as "removed", triggering an erroneous full-rule refresh. Replace the silent fallback with throw new IllegalStateException(...) that wraps the original cause. Since Jackson 3 (tools.jackson) throws unchecked JacksonException, the try/catch was already legacy from the Jackson 2 (com.fasterxml.jackson) era; fail-fast surfaces the real issue at startup or refresh time instead of corrupting the diff baseline. Adds three unit tests covering: equal-but-distinct deep copy, copy independence from the original, and IllegalStateException propagation when Jackson serialization fails.
oss-sentinel-ai
left a comment
There was a problem hiding this comment.
Summary
Clean, focused fix. The original copy() silently swallowed all Jackson exceptions and returned a default-valued instance, which would cause CircuitBreakerRuleChangeListener to compare live properties against an empty backup — producing false-positive change detections. Replacing the silent fallback with IllegalStateException (fail-fast) is the correct approach. Good test coverage including deep-copy isolation verification.
LGTM — no issues found.
Automated review by github-manager-bot
|
Thanks for the fix. My understanding from the PR description is that if SentinelFeignClientProperties#copy() fails during Jackson serialization or deserialization, the previous implementation silently returns a default configuration. The subsequent comparison may then incorrectly detect a configuration change and trigger an unnecessary full rule refresh. However, the current test simulates this failure by overriding getRules() in an anonymous subclass and making it throw an exception. Could you clarify the concrete real-world scenario in which the Jackson operation fails? For example, is it caused by the Jackson 3 migration, a custom subclass or proxy of SentinelFeignClientProperties, or a particular YAML configuration or rule value? If available, a minimal reproduction, exception stack trace, or related user report would help us understand the impact and determine whether failing fast during application startup or configuration refresh is the intended behavior. |
The failure test used an anonymous subclass whose getRules() throws. An anonymous class is itself undeserializable by Jackson - it fails with 'Cannot deserialize Class $1 (of type local/anonymous) as a Bean' even when nothing is overridden - so the carrier carried a second, independent failure source and the test did not isolate what it claimed to test. Replace it with a named static subclass whose getter throws only when armed, and add a control case asserting that the very same carrier round trips cleanly when the getter is disarmed. The asserted failure can then only originate from the throwing getter. Also tighten the assertion to the root cause and its message instead of the broad RuntimeException, which JacksonException also matches.
|
Thanks for pushing on this — you were right to, on both counts. The honest answer to your first question is that I could not find a realistic scenario in which the Jackson operation fails. I probed the candidates you listed, on this branch:
So this is not fixing a failure users are hitting, and I should not have written the description as if it were. I have rewritten it to say so plainly. What I think the change is still worth, and you can judge whether that clears the bar: the fallback is unreachable in practice but not harmless if it is ever reached. It returns On your second question — whether failing fast is the intended behaviour — my recommendation is to keep the throw, for a reason specific to the two call sites. At
Throwing avoids picking either: at startup the failure surfaces with its cause attached, and at refresh time You were also right about the test, and it was worse than it looked. The anonymous subclass is undeserializable by Jackson on its own — dropping the throwing so the carrier brought a second, independent failure source and the test was not isolating what it claimed to. Fixed in the latest commit: the carrier is now a named static subclass whose getter throws only when armed, plus a control case asserting that the very same carrier round-trips cleanly once disarmed — so the asserted failure can only come from the getter. I also tightened the assertion to the root cause and its message, since the old Verified against the pre-fix implementation: |
oss-sentinel-ai
left a comment
There was a problem hiding this comment.
Re-reviewed the new commit fd2afd0 pushed since my last approval — it is test-only and strengthens the suite:
- Replaces the anonymous-subclass failure carrier with the named static
RuleAccessFailureProperties, a more realistic Jackson carrier;failOnRuleAccessdeliberately has no accessors so the serialized form stays identical to the base class. - Tightens assertions to verify the root cause type and message, i.e. the original failure is actually preserved through
IllegalStateException. - Adds the control case
copySucceedsForTheSameSubclassWhenTheGetterDoesNotThrow, proving the asserted failure can only originate from the throwing getter and not from the carrier type itself.
The previously reviewed production change (fail-fast IllegalStateException instead of silently returning a default-valued base-class instance) is unchanged and remains sound: ABI-compatible, preserves the cause, and avoids the corrupted empty baseline in CircuitBreakerRuleChangeListener. Thanks for the thorough write-up and tests.
Automated review by github-manager-bot
Describe what this PR does / why we need it
SentinelFeignClientProperties#copy()produces the deep snapshot thatCircuitBreakerRuleChangeListenerdiffs against to decide whether circuit-breaker rules changed (afterSingletonsInstantiated()andupdateBackup()). Today it swallows every exception and returns a brand-new default-valued instance.To be upfront about the scope: this is not a bug users are hitting today. I probed the round-trip on this branch and could not make it fail in any realistic configuration — plain objects, CGLIB proxies (what AOP /
@RefreshScopeproduce), named subclasses,DegradeRulesubclasses carrying extra fields,count = NaN, and two map keys aliasing oneListall round-trip successfully, and Jackson 3 defaultsFAIL_ON_UNKNOWN_PROPERTIESto off so unrecognised properties are tolerated too.The argument for this change is therefore not "it fixes a live failure" but "the fallback is unreachable in practice and harmful if ever reached":
new SentinelFeignClientProperties()— the base class — whileequals()is implemented withgetClass() != o.getClass(). When the live bean is a proxy or a subclass,Objects.equals(properties, propertiesBackup)inonApplicationEventcannot returntrueagain for the rest of the process lifetime, so every subsequent refresh event is treated as a rule change, silently.Also worth noting: since the module moved to Jackson 3 (
tools.jackson.databind.ObjectMapper),JacksonExceptionis unchecked, so the originaltry / catch (Exception)no longer serves any compile-time purpose either.Does this pull request fix one issue?
NONE
Describe how you did it
Replaced the silent-swallow + new-instance fallback with
throw new IllegalStateException("Failed to deep-copy SentinelFeignClientProperties via Jackson", e);, preserving the original cause.Describe how to verify it
mvn -pl spring-cloud-alibaba-starters/spring-cloud-circuitbreaker-sentinel -am test— full module suite (19 tests) passes locally.SentinelFeignClientPropertiesTestcovers:copyReturnsEqualButDistinctInstance—copy()returns a value.equals()to the original but not the same reference.modifyingCopyDoesNotAffectOriginal— the copy is a true deep copy.copyFailsFastWhenJacksonCannotSerialize— a named static subclass whosegetRules()throws only when armed; asserts the call propagatesIllegalStateExceptionwith the underlying failure as its root cause.copySucceedsForTheSameSubclassWhenTheGetterDoesNotThrow— control case: the very same carrier round-trips cleanly once disarmed, so the failure asserted above can only originate from the throwing getter and not from the carrier type.Verified against the pre-fix implementation:
copyFailsFastWhenJacksonCannotSerializeis the only test that fails there; the control case passes under both implementations.Special notes for reviews
IllegalStateExceptionis unchecked, so thecopy()signature is unchanged.updateBackup()a previous backup does exist, but atafterSingletonsInstantiated()there is none — that call is the first assignment topropertiesBackup. A warn-and-continue variant would have to leave it eithernullor empty there, and both have concrete downsides: leaving itnullmakesclearFeignClientRulesInDegradeManager()return early on the next refresh, so rules the user removed from configuration stay behind inDegradeRuleManager; leaving it empty is exactly today's behaviour. Throwing keeps the two call sites honest instead: at startup the failure surfaces with its cause attached, and at refresh time Spring'sSimpleApplicationEventMulticasterlogs the listener exception and continues to other listeners, so the worst case is a skipped refresh cycle rather than a corrupted baseline.+3 / -2plus the test file.