I have a service, ItemService
, that uses a Feign client to get data from an external service. I want to cache the results of the external service and refresh them every X minutes, unless the external service fails, in which case I want to keep using the previous result (or an empty list if the initial call fails).
I've tried using Spring's cache abstraction, catching all exceptions, and setting 'null' to be disregarded using unless
, but this doesn't work and instead getItems()
returns null!
@Service
class ItemService(
private val itemFeignClient: ItemFeignClient,
) {
@Cacheable(
cacheManager = "itemClientCacheManager",
cacheNames = ["get_items"],
unless = "#result == null", // this does not work
)
fun getItems(): List<String>? {
return try {
itemFeignClient.getItems()
} catch (e: Exception) {
// if the Feign client fails for any reason, I don't want the cache to update
null
}
}
}
I using the Spring cache abstraction in other parts of the code, so I would like to keep using it - but I'm not tied to it. Maybe there's an option for Feign to ignore errors?
Here is a summary of the test I am using
@SpringBootTest(
classes = [
ClientConfig::class,
ItemService::class,
ItemFeignClient::class,
]
)
@EnableFeignClients(
clients = [ItemFeignClient::class]
)
class ItemServiceTest {
@Autowired
lateinit var itemService: ItemService
@Test
fun `when a valid result is cached, expect it is not replaced after an error`() {
stubItemServiceResponse(
httpCode = 200,
responseBody = """
[ "item 1", "item 2"]
""".trimIndent()
)
val responseBeforeError = itemService.getItems()
verifyItemServiceCalled(expectedCount = 1) // assertion succeeds ✅
assertEquals(listOf("item 1", "item 2"), responseBeforeError) // assertion succeeds ✅
// mock an exception from the Feign client
stubItemServiceResponse(
httpCode = 500
)
val responseAfterError = itemService.getItems()
verifyItemServiceCalled(expectedCount = 2) // assertion succeeds ✅
assertEquals(listOf("item 1", "item 2"), responseAfterError) // assertion fails ❌
// assertion failure:
// expected ["item 1", "item 2"], actual: null
}
fun stubItemServiceResponse(
httpCode: Int,
responseBody: String? = null,
) {
// (stubbed using Wiremock)
}
fun verifyItemServiceCalled(
expectedCount: Int
) {
// (stubbed using Wiremock)
}
}
I am certain that the caching is working in the test, because this test succeeds:
@Test
fun `expect multiple calls are cached`() {
stubItemServiceResponse(
httpCode = 200,
responseBody = """
[ "item 1", "item 2"]
""".trimIndent()
)
itemService.getItems()
itemService.getItems()
itemService.getItems()
verifyItemServiceCalled(expectedCount = 1) // assertion succeeds ✅
}
Here is the cache config. I am using Caffeine Cache.
@Configuration
@EnableCaching
class ClientConfig {
@Bean
@Qualifier("itemClientCacheManager")
fun itemClientCacheManager(): CaffeineCacheManager {
val manager = CaffeineCacheManager(
"get_items",
)
manager.setCaffeine(
Caffeine.newBuilder()
.maximumSize(10)
.expireAfterWrite(5, TimeUnit.MINUTES)
)
return manager
}
}
Versions:
- Spring Boot
2.7.0
- Caffeine
3.1.1
- Spring Cloud
2021.0.3
- OpenFeign
3.1.3
- Kotlin
1.7.0