fixing web-app

This commit is contained in:
stefan
2025-09-24 14:21:57 +02:00
parent cd2b0796a6
commit 1c4184809a
156 changed files with 440 additions and 1708 deletions
@@ -0,0 +1,87 @@
package at.mocode.ping.service
import at.mocode.ping.api.PingResponse
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
@RestController
@CrossOrigin(
origins = ["http://localhost:8080", "http://localhost:8083", "http://localhost:4000"],
methods = [RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.OPTIONS],
allowedHeaders = ["*"],
allowCredentials = "true"
)
class LegacyPingController(
private val pingService: PingServiceCircuitBreaker,
private val circuitBreakerRegistry: CircuitBreakerRegistry
) {
@GetMapping("/ping", produces = [MediaType.APPLICATION_JSON_VALUE])
fun legacySimplePing(): ResponseEntity<PingResponse> {
val now = OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
val resp = PingResponse(
status = "pong",
timestamp = now,
service = "ping-service"
)
return ResponseEntity
.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(resp)
}
@GetMapping("/ping/enhanced")
fun legacyEnhanced(@RequestParam(required = false, defaultValue = "false") simulate: Boolean): Map<String, Any> {
val dto = pingService.ping(simulate)
val map = mutableMapOf<String, Any>(
"status" to dto.status,
"timestamp" to dto.timestamp,
"service" to dto.service,
"circuitBreaker" to dto.circuitBreakerState
)
if (dto.status.equals("fallback", ignoreCase = true) || dto.service.contains("fallback")) {
map["message"] = "Service temporarily unavailable"
map["error"] = UUID.randomUUID().toString()
}
return map
}
@GetMapping("/ping/health")
fun legacyHealth(): Map<String, Any> {
val dto = pingService.healthCheck()
val state = circuitBreakerRegistry
.circuitBreaker(PingServiceCircuitBreaker.PING_CIRCUIT_BREAKER)
.state
val cb = if (state.name == "OPEN") "OPEN" else "CLOSED"
val map = mutableMapOf<String, Any>(
"status" to if (dto.healthy) "UP" else "DOWN",
"timestamp" to dto.timestamp,
"circuitBreaker" to cb
)
if (!dto.healthy) {
map["message"] = "Health check temporarily unavailable"
}
return map
}
@GetMapping("/ping/test-failure")
fun legacyTestFailure(): Map<String, Any> {
val dto = pingService.ping(simulateFailure = true)
val map = mutableMapOf<String, Any>(
"status" to dto.status,
"timestamp" to dto.timestamp,
"service" to dto.service,
"circuitBreaker" to dto.circuitBreakerState
)
if (dto.status.equals("fallback", ignoreCase = true) || dto.service.contains("fallback")) {
map["message"] = "Service temporarily unavailable"
map["error"] = UUID.randomUUID().toString()
}
return map
}
}
@@ -0,0 +1,40 @@
package at.mocode.ping.service
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import at.mocode.ping.api.PingApi
import at.mocode.ping.api.PingResponse
import org.springframework.web.bind.annotation.*
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
@RestController
@CrossOrigin(
origins = ["http://localhost:8080", "http://localhost:8083", "http://localhost:4000"],
methods = [RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.OPTIONS],
allowedHeaders = ["*"],
allowCredentials = "true"
)
class PingController(
private val pingService: PingServiceCircuitBreaker
) : PingApi {
// Contract endpoints
@GetMapping("/api/ping/simple")
override suspend fun simplePing(): PingResponse {
val now = OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
return PingResponse(
status = "pong",
timestamp = now,
service = "ping-service"
)
}
@GetMapping("/api/ping/enhanced")
override suspend fun enhancedPing(
@RequestParam(required = false, defaultValue = "false") simulate: Boolean
): EnhancedPingResponse = pingService.ping(simulate)
@GetMapping("/api/ping/health")
override suspend fun healthCheck(): HealthResponse = pingService.healthCheck()
}
@@ -0,0 +1,32 @@
package at.mocode.ping.service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.EnableAspectJAutoProxy
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@SpringBootApplication
@EnableAspectJAutoProxy
class PingServiceApplication {
@Bean
fun corsConfigurer(): WebMvcConfigurer {
return object : WebMvcConfigurer {
override fun addCorsMappings(registry: CorsRegistry) {
registry.addMapping("/**")
.allowedOriginPatterns("http://localhost:*")
.allowedOrigins("http://localhost:8080", "http://localhost:8083", "http://localhost:4000")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600)
}
}
}
}
fun main(args: Array<String>) {
runApplication<PingServiceApplication>(*args)
}
@@ -0,0 +1,109 @@
package at.mocode.ping.service
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlin.random.Random
/**
* Service demonstrating a Circuit Breaker pattern with Resilience
*
* This service simulates potential failures and uses circuit breaker
* to handle service degradation gracefully with fallback responses.
*/
@Service
class PingServiceCircuitBreaker {
private val logger = LoggerFactory.getLogger(PingServiceCircuitBreaker::class.java)
companion object {
const val PING_CIRCUIT_BREAKER = "pingCircuitBreaker"
private val formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME //.ofPattern("yyyy-MM-dd HH:mm:ss")
}
/**
* Primary ping method with circuit breaker protection returning DTO directly
*
* @param simulateFailure - if true, randomly throws exceptions to test circuit breaker
*/
@CircuitBreaker(name = PING_CIRCUIT_BREAKER, fallbackMethod = "fallbackPing")
fun ping(simulateFailure: Boolean = false): EnhancedPingResponse {
val start = System.nanoTime()
logger.info("Executing ping service call...")
if (simulateFailure && Random.nextDouble() < 0.6) {
logger.warn("Simulating service failure for circuit breaker testing")
throw RuntimeException("Simulated service failure")
}
val currentTime = LocalDateTime.now().atOffset(java.time.ZoneOffset.UTC).format(formatter)
val elapsedMs = (System.nanoTime() - start) / 1_000_000
logger.info("Ping service call successful")
return EnhancedPingResponse(
status = "pong",
timestamp = currentTime,
service = "ping-service",
circuitBreakerState = "CLOSED",
responseTime = elapsedMs
)
}
/**
* Fallback method called when circuit breaker is OPEN
*
* @param simulateFailure - original parameter (ignored in fallback)
* @param exception - the exception that triggered the fallback
*/
fun fallbackPing(simulateFailure: Boolean = false, exception: Exception): EnhancedPingResponse {
val start = System.nanoTime()
// Die volle Exception nur loggen, nicht an den Client weitergeben.
logger.warn("Circuit breaker fallback triggered due to: {}", exception.toString())
val currentTime = LocalDateTime.now().atOffset(java.time.ZoneOffset.UTC).format(formatter)
val elapsedMs = (System.nanoTime() - start) / 1_000_000
return EnhancedPingResponse(
status = "fallback",
timestamp = currentTime,
service = "ping-service-fallback",
circuitBreakerState = "OPEN",
responseTime = elapsedMs
)
}
/**
* Health check method with circuit breaker protection returning DTO directly
*/
@CircuitBreaker(name = PING_CIRCUIT_BREAKER, fallbackMethod = "fallbackHealth")
fun healthCheck(): HealthResponse {
logger.info("Executing health check...")
val currentTime = LocalDateTime.now().atOffset(java.time.ZoneOffset.UTC).format(formatter)
return HealthResponse(
status = "pong",
timestamp = currentTime,
service = "ping-service",
healthy = true
)
}
/**
* Fallback for health check returning DTO
*/
fun fallbackHealth(exception: Exception): HealthResponse {
logger.warn("Health check fallback triggered: {}", exception.message)
val currentTime = LocalDateTime.now().atOffset(java.time.ZoneOffset.UTC).format(formatter)
return HealthResponse(
status = "down",
timestamp = currentTime,
service = "ping-service",
healthy = false
)
}
}
@@ -0,0 +1,73 @@
spring:
application:
name: ping-service
cloud:
consul:
host: ${CONSUL_HOST:localhost}
port: ${CONSUL_PORT:8500}
discovery:
enabled: true
register: true
health-check-path: /actuator/health
health-check-interval: 10s
server:
port: ${SERVER_PORT:${PING_SERVICE_PORT:8082}}
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,circuitbreakers
endpoint:
health:
show-details: always
probes:
enabled: true
tracing:
# Disable tracing by default to avoid Zipkin connection errors
enabled: ${TRACING_ENABLED:false}
sampling:
probability: ${TRACING_SAMPLING_PROBABILITY:0.1}
zipkin:
tracing:
# Only configure endpoint if tracing is explicitly enabled
endpoint: ${ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans}
# Configure timeout and connection settings to handle missing Zipkin gracefully
connect-timeout: 1s
read-timeout: 5s
# Resilience4j Circuit Breaker Configuration
resilience4j:
circuitbreaker:
configs:
default:
# Circuit breaker opens when the failure rate exceeds 50%
failure-rate-threshold: 50
# Minimum number of calls to calculate the failure rate
minimum-number-of-calls: 5
# Time to wait before transitioning from OPEN to HALF_OPEN
wait-duration-in-open-state: 10s
# Number of calls in HALF_OPEN state before deciding to close/open
permitted-number-of-calls-in-half-open-state: 3
# Sliding window size for calculating failure rate
sliding-window-size: 10
# Type of sliding window (COUNT_BASED or TIME_BASED)
sliding-window-type: COUNT_BASED
# Record exceptions that should be considered as failures
record-exceptions:
- java.lang.Exception
# Ignore certain exceptions (don't count as failures)
ignore-exceptions:
- java.lang.IllegalArgumentException
instances:
pingCircuitBreaker:
# Use default configuration
base-config: default
# Override specific settings for this instance if needed
failure-rate-threshold: 60
minimum-number-of-calls: 4
wait-duration-in-open-state: 5s
# Metrics configuration removed to avoid property resolution warnings
# Use micrometer and actuator endpoints for metrics instead
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="LOG_PATTERN" value="%d{ISO8601} %-5level [%X{traceId:-}:%X{spanId:-}] %logger{36} - %msg%n"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<logger name="org.springframework" level="INFO"/>
<logger name="org.springframework.web" level="INFO"/>
<logger name="org.springframework.boot.actuate" level="INFO"/>
<logger name="reactor.netty" level="WARN"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
@@ -0,0 +1,272 @@
package at.mocode.ping.service
import io.github.resilience4j.circuitbreaker.CircuitBreaker
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.http.HttpStatus
import org.springframework.test.context.ActiveProfiles
/**
* Integration tests for PingController
* Tests REST endpoints with circuit breaker functionality using TestRestTemplate
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class PingControllerIntegrationTest {
@LocalServerPort
private var port: Int = 0
@Autowired
private lateinit var restTemplate: TestRestTemplate
@Autowired
private lateinit var circuitBreakerRegistry: CircuitBreakerRegistry
private val logger = LoggerFactory.getLogger(PingControllerIntegrationTest::class.java)
private lateinit var circuitBreaker: CircuitBreaker
@BeforeEach
fun setUp() {
circuitBreaker = circuitBreakerRegistry.circuitBreaker(PingServiceCircuitBreaker.PING_CIRCUIT_BREAKER)
// Reset circuit breaker state before each test
circuitBreaker.reset()
}
private fun getUrl(endpoint: String) = "http://localhost:$port$endpoint"
@Test
fun `should return basic ping response from standard endpoint`() {
// When
val response = restTemplate.getForEntity(getUrl("/ping"), Map::class.java)
// Then
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
assertThat(response.body).isNotNull
assertThat(response.body!!["status"]).isEqualTo("pong")
logger.info("Standard ping endpoint response: {}", response.body)
}
@Test
fun `should return enhanced ping response when circuit breaker is closed`() {
// Given
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
// When
val response = restTemplate.getForEntity(getUrl("/ping/enhanced"), Map::class.java)
// Then
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
assertThat(response.body).isNotNull
val body = response.body!!
assertThat(body["status"]).isEqualTo("pong")
assertThat(body["service"]).isEqualTo("ping-service")
assertThat(body["circuitBreaker"]).isEqualTo("CLOSED")
assertThat(body["timestamp"]).isNotNull()
logger.info("Enhanced ping response: {}", body)
}
@Test
fun `should return enhanced ping response without simulation`() {
// When
val response = restTemplate.getForEntity(getUrl("/ping/enhanced?simulate=false"), Map::class.java)
// Then
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
assertThat(response.body).isNotNull
val body = response.body!!
assertThat(body["status"]).isEqualTo("pong")
assertThat(body["service"]).isEqualTo("ping-service")
assertThat(body["circuitBreaker"]).isEqualTo("CLOSED")
logger.info("Enhanced ping without simulation: {}", body)
}
@Test
fun `should handle failure simulation in enhanced ping endpoint`() {
// Multiple calls to potentially trigger failures due to random simulation
repeat(3) { i ->
val response = restTemplate.getForEntity(getUrl("/ping/enhanced?simulate=true"), Map::class.java)
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
assertThat(response.body).isNotNull
val body = response.body!!
logger.info("Attempt {}: Response status = {}, Circuit breaker state = {}",
i + 1, body["status"], circuitBreaker.state)
// Response should be either success or fallback
assertThat(body["status"]).isIn("pong", "fallback")
}
}
@Test
fun `should return health check response when circuit breaker is closed`() {
// Given
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
// When
val response = restTemplate.getForEntity(getUrl("/ping/health"), Map::class.java)
// Then
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
assertThat(response.body).isNotNull
val body = response.body!!
assertThat(body["status"]).isEqualTo("UP")
assertThat(body["circuitBreaker"]).isEqualTo("CLOSED")
assertThat(body["timestamp"]).isNotNull()
logger.info("Health check response: {}", body)
}
@Test
fun `should return fallback health check when circuit breaker is open`() {
// Given - manually open circuit breaker
circuitBreaker.transitionToOpenState()
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.OPEN)
// When
val response = restTemplate.getForEntity(getUrl("/ping/health"), Map::class.java)
// Then
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
assertThat(response.body).isNotNull
val body = response.body!!
assertThat(body["status"]).isEqualTo("DOWN")
assertThat(body["circuitBreaker"]).isEqualTo("OPEN")
assertThat(body["message"]).isEqualTo("Health check temporarily unavailable")
logger.info("Fallback health check response: {}", body)
}
@Test
fun `should return response from test-failure endpoint`() {
// When
val response = restTemplate.getForEntity(getUrl("/ping/test-failure"), Map::class.java)
// Then
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
assertThat(response.body).isNotNull
val body = response.body!!
// Due to 60% failure simulation, we expect either success or fallback
assertThat(body["status"]).isIn("pong", "fallback")
if (body["status"] == "fallback") {
assertThat(body["service"]).isEqualTo("ping-service-fallback")
assertThat(body["circuitBreaker"]).isEqualTo("OPEN")
assertThat(body["message"]).isEqualTo("Service temporarily unavailable")
assertThat(body["error"]).isNotNull()
} else {
assertThat(body["service"]).isEqualTo("ping-service")
assertThat(body["circuitBreaker"]).isEqualTo("CLOSED")
}
logger.info("Test failure endpoint response: {}", body)
}
@Test
fun `should handle multiple rapid requests correctly`() {
// Execute multiple rapid requests
val results = mutableListOf<Map<String, Any>>()
repeat(5) { i ->
val response = restTemplate.getForEntity(getUrl("/ping/enhanced"), Map::class.java)
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
assertThat(response.body).isNotNull
@Suppress("UNCHECKED_CAST")
val body = response.body as Map<String, Any>
results.add(body)
logger.info("Rapid request {}: status = {}", i + 1, body["status"])
}
// All should be successful since we're not simulating failures
results.forEach { response ->
assertThat(response["status"]).isEqualTo("pong")
assertThat(response["service"]).isEqualTo("ping-service")
}
}
@Test
fun `should maintain circuit breaker state across requests`() {
// Given - manually open circuit breaker
circuitBreaker.transitionToOpenState()
// When - make multiple requests
repeat(3) { i ->
val response = restTemplate.getForEntity(getUrl("/ping/enhanced"), Map::class.java)
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
assertThat(response.body).isNotNull
val body = response.body!!
// All should return fallback responses while circuit breaker is open
assertThat(body["status"]).isEqualTo("fallback")
assertThat(body["circuitBreaker"]).isEqualTo("OPEN")
logger.info("Request {} with OPEN circuit breaker: {}", i + 1, body["status"])
}
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.OPEN)
}
@Test
fun `should test all endpoints return valid responses`() {
val endpoints = listOf(
"/ping",
"/ping/enhanced",
"/ping/health",
"/ping/test-failure"
)
endpoints.forEach { endpoint ->
val response = restTemplate.getForEntity(getUrl(endpoint), Map::class.java)
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
assertThat(response.body).isNotNull()
assertThat(response.body!!).isNotEmpty()
logger.info("Endpoint {} returned valid response: {}", endpoint, response.body)
}
}
@Test
fun `should track circuit breaker metrics after calls`() {
// Given
val initialMetrics = circuitBreaker.metrics
logger.info("Initial metrics - Calls: {}, Failures: {}",
initialMetrics.numberOfBufferedCalls, initialMetrics.numberOfFailedCalls)
// When - execute some calls
repeat(3) {
restTemplate.getForEntity(getUrl("/ping/enhanced"), Map::class.java)
}
// Then
val newMetrics = circuitBreaker.metrics
assertThat(newMetrics.numberOfBufferedCalls).isGreaterThanOrEqualTo(3)
logger.info("Updated metrics - Calls: {}, Failure rate: {}%, Successful: {}, Failed: {}",
newMetrics.numberOfBufferedCalls,
newMetrics.failureRate,
newMetrics.numberOfSuccessfulCalls,
newMetrics.numberOfFailedCalls)
}
}
@@ -0,0 +1,38 @@
package at.mocode.ping.service
import io.mockk.mockk
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
@WebMvcTest(LegacyPingController::class)
@Import(PingControllerTest.TestConfig::class)
class PingControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
@TestConfiguration
class TestConfig {
@Bean
fun pingServiceCircuitBreaker(): PingServiceCircuitBreaker = mockk()
@Bean
fun circuitBreakerRegistry(): CircuitBreakerRegistry = mockk()
}
@Test
fun `ping endpoint should return pong status`() {
mockMvc.perform(get("/ping"))
.andExpect(status().isOk)
.andExpect(content().contentType("application/json"))
.andExpect(jsonPath("$.status").value("pong"))
}
}
@@ -0,0 +1,216 @@
package at.mocode.ping.service
import io.github.resilience4j.circuitbreaker.CircuitBreaker
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import kotlin.math.ceil
/**
* Comprehensive test suite for PingServiceCircuitBreaker
* Updated to assert DTOs instead of Maps.
*/
@SpringBootTest
class PingServiceCircuitBreakerTest {
@Autowired
private lateinit var pingServiceCircuitBreaker: PingServiceCircuitBreaker
@Autowired
private lateinit var circuitBreakerRegistry: CircuitBreakerRegistry
private val logger = LoggerFactory.getLogger(PingServiceCircuitBreakerTest::class.java)
private lateinit var circuitBreaker: CircuitBreaker
@BeforeEach
fun setUp() {
circuitBreaker = circuitBreakerRegistry.circuitBreaker(PingServiceCircuitBreaker.PING_CIRCUIT_BREAKER)
// Reset circuit breaker state before each test
circuitBreaker.reset()
}
@Test
fun `should return successful ping response when circuit breaker is closed`() {
// Given
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
// When
val result = pingServiceCircuitBreaker.ping(simulateFailure = false)
// Then
assertThat(result.status).isEqualTo("pong")
assertThat(result.service).isEqualTo("ping-service")
assertThat(result.circuitBreakerState).isEqualTo("CLOSED")
assertThat(result.timestamp).isNotBlank()
assertThat(result.responseTime).isGreaterThanOrEqualTo(0)
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
}
@Test
fun `should handle single failure without opening circuit breaker`() {
// Given
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
// When - try until we hit a simulated failure (60% chance)
var result = pingServiceCircuitBreaker.ping(simulateFailure = true)
var attempts = 1
while (result.status == "pong" && attempts < 10) {
result = pingServiceCircuitBreaker.ping(simulateFailure = true)
attempts++
}
// Then - should get fallback response eventually
assertThat(result.status).isEqualTo("fallback")
assertThat(result.service).isEqualTo("ping-service-fallback")
assertThat(result.circuitBreakerState).isEqualTo("OPEN")
logger.info("Circuit breaker state after single failure (after {} attempts): {}", attempts, circuitBreaker.state)
}
@Test
fun `should open circuit breaker after multiple failures`() {
// Given
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
// When - trigger multiple failures to reach minimum-number-of-calls (4) and failure threshold (60%)
var failureCount = 0
var totalCalls = 0
val maxAttempts = 20 // Prevent infinite loop
while (circuitBreaker.state == CircuitBreaker.State.CLOSED && totalCalls < maxAttempts) {
val result = pingServiceCircuitBreaker.ping(simulateFailure = true)
totalCalls++
if (result.status == "fallback") failureCount++
logger.info(
"Attempt {}: Circuit breaker state = {}, Response status = {}, Failures so far = {}",
totalCalls, circuitBreaker.state, result.status, failureCount
)
}
// Then - circuit breaker should be open after sufficient failures
logger.info(
"Final circuit breaker state: {} after {} total calls with {} failures",
circuitBreaker.state, totalCalls, failureCount
)
if (totalCalls >= 4 && failureCount >= ceil(totalCalls * 0.6)) {
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.OPEN)
}
}
@Test
fun `should return fallback response when circuit breaker is manually opened`() {
// Given
circuitBreaker.transitionToOpenState()
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.OPEN)
// When
val result = pingServiceCircuitBreaker.ping(simulateFailure = false)
// Then
assertThat(result.status).isEqualTo("fallback")
assertThat(result.service).isEqualTo("ping-service-fallback")
assertThat(result.circuitBreakerState).isEqualTo("OPEN")
}
@Test
fun `should return successful health check when circuit breaker is closed`() {
// Given
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
// When
val result = pingServiceCircuitBreaker.healthCheck()
// Then
assertThat(result.healthy).isTrue()
assertThat(result.status).isEqualTo("pong")
assertThat(result.timestamp).isNotBlank()
}
@Test
fun `should return fallback health check when circuit breaker is open`() {
// Given
circuitBreaker.transitionToOpenState()
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.OPEN)
// When
val result = pingServiceCircuitBreaker.healthCheck()
// Then
assertThat(result.healthy).isFalse()
assertThat(result.status).isEqualTo("down")
}
@Test
fun `should test circuit breaker state transitions`() {
// Given
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
// When - manually transition to open state
circuitBreaker.transitionToOpenState()
// Then
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.OPEN)
// When - manually transition to half-open state
circuitBreaker.transitionToHalfOpenState()
// Then
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.HALF_OPEN)
// When - successful call should close circuit breaker
val result = pingServiceCircuitBreaker.ping(simulateFailure = false)
// Then
assertThat(result.status).isEqualTo("pong")
logger.info("Circuit breaker state after successful call in HALF_OPEN: {}", circuitBreaker.state)
}
@Test
fun `should track circuit breaker metrics`() {
// Given
val metrics = circuitBreaker.metrics
val initialNumberOfCalls = metrics.numberOfBufferedCalls
// When - execute some successful calls
repeat(3) {
pingServiceCircuitBreaker.ping(simulateFailure = false)
}
// Then
val newMetrics = circuitBreaker.metrics
assertThat(newMetrics.numberOfBufferedCalls).isGreaterThan(initialNumberOfCalls)
logger.info(
"Circuit breaker metrics - Calls: {}, Failure rate: {}%, Successful calls: {}, Failed calls: {}",
newMetrics.numberOfBufferedCalls,
newMetrics.failureRate,
newMetrics.numberOfSuccessfulCalls,
newMetrics.numberOfFailedCalls
)
}
@Test
fun `should handle concurrent calls correctly`() {
// Given
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
// When - execute concurrent calls
val threads = (1..10).map { index ->
Thread {
val result = pingServiceCircuitBreaker.ping(simulateFailure = false)
logger.info("Concurrent call {}: status = {}", index, result.status)
}
}
threads.forEach { it.start() }
threads.forEach { it.join() }
// Then - verify circuit breaker recorded calls
val metrics = circuitBreaker.metrics
assertThat(metrics.numberOfBufferedCalls).isGreaterThanOrEqualTo(10)
assertThat(metrics.numberOfSuccessfulCalls).isGreaterThanOrEqualTo(10)
}
}
@@ -0,0 +1,53 @@
spring:
application:
name: ping-service-test
cloud:
consul:
enabled: false
discovery:
enabled: false
register: false
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,info,circuitbreakers
endpoint:
health:
show-details: always
# Resilience4j Circuit Breaker Configuration for tests
resilience4j:
circuitbreaker:
configs:
default:
failure-rate-threshold: 50
minimum-number-of-calls: 5
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 3
sliding-window-size: 10
sliding-window-type: COUNT_BASED
record-exceptions:
- java.lang.Exception
ignore-exceptions:
- java.lang.IllegalArgumentException
instances:
pingCircuitBreaker:
base-config: default
failure-rate-threshold: 60
minimum-number-of-calls: 4
wait-duration-in-open-state: 5s
metrics:
enabled: true
legacy:
enabled: true
logging:
level:
org.springframework.cloud.consul: ERROR
com.ecwid.consul: ERROR