fix: resolve NoClassDefFoundError when Micrometer is absent (issue #94)#95
Conversation
…on classpath - TurnstileServiceConfig: add noOpTurnstileMetrics() (@ConditionalOnMissingBean) and turnstileValidationService() factory method injecting TurnstileMetrics - TurnstileMetricsConfig: add micrometerTurnstileMetrics() bean (@ConditionalOnClass(MeterRegistry.class)) - TurnstileValidationService: remove @service — bean is now registered exclusively via factory method
… prevent NoClassDefFoundError when Micrometer is absent
…ator Verifies the library starts correctly when Micrometer auto-configurations are excluded via spring.autoconfigure.exclude, covering context load, metrics bean availability, validation functionality, and internal counter tracking without throwing NoClassDefFoundError.
…ath regression)
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Fixes NoClassDefFoundError when Micrometer isn’t on a consumer’s classpath by isolating Micrometer-specific references behind a TurnstileMetrics abstraction and switching to safe, string-based conditional loading.
Changes:
- Introduces
TurnstileMetricswithNoOpTurnstileMetricsfallback andMicrometerTurnstileMetricsimplementation. - Refactors
TurnstileValidationServiceand config wiring to avoid unconditional Micrometer type references. - Updates auto-configuration to use
@ConditionalOnClass(name=...)and adds a Spring wiring sanity test.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileMetricsWiringTest.java | Adds a context wiring test around metrics exclusions/classpath conditions. |
| src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java | Replaces direct Micrometer usage with TurnstileMetrics calls and records timings in finally. |
| src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java | Adds the metrics abstraction used by the service layer. |
| src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java | Adds no-op metrics implementation for non-Micrometer environments. |
| src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java | Adds Micrometer-backed implementation encapsulating counters/timer usage. |
| src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java | Wires TurnstileMetrics into the service and provides a default no-op bean. |
| src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java | Registers the Micrometer-backed bean and applies MeterRegistry customizations. |
| src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java | Switches conditional import to string-based @ConditionalOnClass to prevent bytecode linkage errors. |
| docs/superpowers/plans/2026-05-01-optional-micrometer-fix.md | Adds an implementation plan documenting the approach and steps. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } catch (TurnstileValidationException e) { | ||
| // Re-throw the TurnstileValidationException | ||
| recordError(ValidationResultType.INVALID_TOKEN); | ||
| throw e; | ||
| } catch (Exception e) { | ||
| // Catch-all for any other unexpected exceptions | ||
| log.error("Unexpected error during Turnstile validation: {}", e.getMessage(), e); | ||
| recordError(ValidationResultType.NETWORK_ERROR); | ||
| throw new TurnstileNetworkException("Unexpected error: " + e.getMessage(), e); | ||
| } finally { | ||
| long elapsed = System.currentTimeMillis() - startTime; | ||
| lastResponseTime.set(elapsed); | ||
| totalResponseTime.addAndGet(elapsed); | ||
| responseCount.incrementAndGet(); | ||
| metrics.recordResponseTime(elapsed); | ||
| } |
There was a problem hiding this comment.
The INVALID_TOKEN error is recorded twice: once in executeValidationRequest(...) before throwing TurnstileValidationException, and again in the outer catch (TurnstileValidationException e) block. This will double-increment internal counters and Micrometer counters for invalid tokens. Prefer recording the error in exactly one place (either remove the recordError(INVALID_TOKEN) in executeValidationRequest(...) or remove it from the catch (TurnstileValidationException e) and just rethrow).
| @@ -344,52 +246,23 @@ private ValidationResult executeValidationRequest(Map<String, String> requestBod | |||
| } | |||
There was a problem hiding this comment.
The INVALID_TOKEN error is recorded twice: once in executeValidationRequest(...) before throwing TurnstileValidationException, and again in the outer catch (TurnstileValidationException e) block. This will double-increment internal counters and Micrometer counters for invalid tokens. Prefer recording the error in exactly one place (either remove the recordError(INVALID_TOKEN) in executeValidationRequest(...) or remove it from the catch (TurnstileValidationException e) and just rethrow).
| @Bean | ||
| @ConditionalOnMissingBean(TurnstileMetrics.class) | ||
| public TurnstileMetrics micrometerTurnstileMetrics(MeterRegistry registry) { | ||
| return new MicrometerTurnstileMetrics(registry); | ||
| } |
There was a problem hiding this comment.
micrometerTurnstileMetrics(...) requires a MeterRegistry bean. If Micrometer classes are present but Boot’s metrics auto-configuration is excluded/disabled (or no registry is created), the context will fail to start due to an unsatisfied dependency. Add a bean-level condition such as @ConditionalOnBean(MeterRegistry.class) (or equivalent) so the Micrometer-backed TurnstileMetrics is only registered when a MeterRegistry bean actually exists, allowing the NoOpTurnstileMetrics fallback to take over.
| * Because Micrometer is still on the test classpath, MicrometerTurnstileMetrics remains active. | ||
| */ | ||
| @Test | ||
| void metricsAvailableWhenMicrometerAutoConfigExcluded() { | ||
| assertNotNull(turnstileMetrics, | ||
| "A TurnstileMetrics bean should be available even with Micrometer auto-config excluded"); | ||
| assertFalse(turnstileMetrics instanceof NoOpTurnstileMetrics, | ||
| "MicrometerTurnstileMetrics should be active when Micrometer is on the classpath"); |
There was a problem hiding this comment.
This test excludes MetricsAutoConfiguration and SimpleMetricsExportAutoConfiguration, which commonly results in no MeterRegistry bean being created. With the intended wiring, that should typically select the NoOpTurnstileMetrics fallback, not the Micrometer-backed implementation. Consider updating the assertion to expect NoOpTurnstileMetrics (or, if the expectation is truly “Micrometer metrics should remain active”, ensure the test context still provides a MeterRegistry bean explicitly).
| * Because Micrometer is still on the test classpath, MicrometerTurnstileMetrics remains active. | |
| */ | |
| @Test | |
| void metricsAvailableWhenMicrometerAutoConfigExcluded() { | |
| assertNotNull(turnstileMetrics, | |
| "A TurnstileMetrics bean should be available even with Micrometer auto-config excluded"); | |
| assertFalse(turnstileMetrics instanceof NoOpTurnstileMetrics, | |
| "MicrometerTurnstileMetrics should be active when Micrometer is on the classpath"); | |
| * Without a MeterRegistry bean in the test context, the NoOpTurnstileMetrics fallback should be active. | |
| */ | |
| @Test | |
| void noOpMetricsActiveWhenMicrometerAutoConfigExcluded() { | |
| assertNotNull(turnstileMetrics, | |
| "A TurnstileMetrics bean should be available even with Micrometer auto-config excluded"); | |
| assertTrue(turnstileMetrics instanceof NoOpTurnstileMetrics, | |
| "NoOpTurnstileMetrics should be active when MeterRegistry auto-configuration is excluded"); |
| import io.micrometer.core.instrument.MeterRegistry; | ||
| import io.micrometer.core.instrument.Tag; | ||
| import io.micrometer.core.instrument.config.MeterFilter; |
There was a problem hiding this comment.
The PR description states that MicrometerTurnstileMetrics is “the only class in the library that references Micrometer types”, but TurnstileMetricsConfig also imports and references Micrometer types (MeterRegistry, Tag, MeterFilter). Either update the PR description to match the implementation, or move Micrometer-specific registry customization into the Micrometer-only section (so the claim remains accurate).
| * This is the fallback when Micrometer is not on the classpath. | ||
| * | ||
| * @return a no-op TurnstileMetrics instance | ||
| */ | ||
| @Bean | ||
| @ConditionalOnMissingBean(TurnstileMetrics.class) | ||
| public TurnstileMetrics noOpTurnstileMetrics() { | ||
| log.info("Micrometer not available — using no-op Turnstile metrics"); |
There was a problem hiding this comment.
The log message “Micrometer not available” can be misleading in cases where Micrometer is present but metrics are disabled by property, or where Micrometer classes are present but no MeterRegistry bean exists. Consider rewording the message to reflect “no MeterRegistry/metrics wiring available” (or similar), or lowering the log level to reduce noise on normal startup paths.
| * This is the fallback when Micrometer is not on the classpath. | |
| * | |
| * @return a no-op TurnstileMetrics instance | |
| */ | |
| @Bean | |
| @ConditionalOnMissingBean(TurnstileMetrics.class) | |
| public TurnstileMetrics noOpTurnstileMetrics() { | |
| log.info("Micrometer not available — using no-op Turnstile metrics"); | |
| * This is the fallback when no TurnstileMetrics bean or metrics wiring is available. | |
| * | |
| * @return a no-op TurnstileMetrics instance | |
| */ | |
| @Bean | |
| @ConditionalOnMissingBean(TurnstileMetrics.class) | |
| public TurnstileMetrics noOpTurnstileMetrics() { | |
| log.info("No TurnstileMetrics bean or metrics wiring available — using no-op Turnstile metrics"); |
| * <p>Note: Micrometer is still on the test classpath (via spring-boot-starter-actuator), so these | ||
| * tests cannot detect a bytecode-level {@code NoClassDefFoundError} regression. They verify that | ||
| * Spring auto-configuration exclusions are respected and that the correct metrics implementation | ||
| * is active given the classpath conditions. |
There was a problem hiding this comment.
The core regression being fixed is a bytecode-level NoClassDefFoundError when Micrometer is absent, but the current tests explicitly run with Micrometer on the test classpath (so they cannot fail in the way consumers reported). To cover the real failure mode, add a test job/source set/submodule that runs a minimal Spring context load with Micrometer/Actuator excluded from the test runtime classpath (not just auto-config exclusions), so classloading/linkage regressions are caught in CI.
…vaDoc, unit tests - Remove duplicate recordError(INVALID_TOKEN) call from executeValidationRequest; outer catch (TurnstileValidationException) is the single correct recording site - Wrap metrics.recordResponseTime() in try/catch inside finally block so a metrics failure cannot suppress the original validation exception - Add log.debug to TurnstileValidationException catch for symmetry with other handlers - Improve broad catch to log exception class name for better diagnostics - Add complete JavaDoc to TurnstileMetrics interface (recordValidation, recordSuccess, recordResponseTime) including call-sequence contract and timing semantics - Add class and constructor JavaDoc to MicrometerTurnstileMetrics; add Objects.requireNonNull guard on registry parameter - Fix TurnstileValidationService class-level doc (no longer says "when metrics enabled") - Fix TurnstileHealthConfiguration comment to mention @ConditionalOnEnabledHealthIndicator - Fix noOpTurnstileMetrics comment to say "no TurnstileMetrics bean registered" rather than "Micrometer not on classpath" - Add MicrometerTurnstileMetricsTest (9 tests) covering all counter dispatch paths, timer recording, null-registry rejection, and aggregate/sub-counter consistency
…ssage Add @ConditionalOnBean(MeterRegistry.class) to micrometerTurnstileMetrics so the Micrometer-backed bean only registers when a MeterRegistry bean actually exists, gracefully falling back to NoOpTurnstileMetrics otherwise. Update noOpTurnstileMetrics log message to accurately describe all fallback scenarios (no Micrometer, metrics disabled, no registry available). Update TurnstileMetricsWiringTest to assert the correct post-fix behavior: NoOpTurnstileMetrics is active when MetricsAutoConfiguration is excluded and no MeterRegistry bean is present.
Summary
TurnstileMetricsinterface with aNoOpTurnstileMetricsfallback (no Micrometer imports) and aMicrometerTurnstileMetricsimplementation — the only class in the library that references Micrometer typesMeterRegistry,Counter,Timer) from classes that load unconditionally:TurnstileValidationService,TurnstileServiceConfig, andTurnstileConfiguration@ConditionalOnClass(MeterRegistry.class)(class literal, unsafe) to@ConditionalOnClass(name = "io.micrometer.core.instrument.MeterRegistry")(string form, safe) inTurnstileConfiguration@ConditionalOnMissingBean(TurnstileMetrics.class)to both metric bean providers so consumers can override the implementationRoot Cause
Since v1.1.8, classes annotated with
@ConditionalOnClass(MeterRegistry.class)and classes withOptional<MeterRegistry>/Counter/Timerfields encoded direct bytecode references to Micrometer types. When Micrometer was absent from a consumer's classpath, the JVM threwNoClassDefFoundError: io/micrometer/core/instrument/MeterRegistryat class-load time before Spring could evaluate any conditional.Test Plan
./gradlew test— 15 tests across 3 classes, all passingTurnstileMetricsWiringTest(3 tests) confirms metrics beans wire correctly when Micrometer auto-configs are excludedimport io.micrometerinTurnstileConfiguration,TurnstileServiceConfig, orTurnstileValidationServicespring-boot-starter-actuatoris presentspring-boot-starter-actuatorCloses #94