0

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
aSemy
  • 5,485
  • 2
  • 25
  • 51
  • Does it fail in integration-test or in running application as well? And also did you try `condition="#result != null"` instead of `unless=` ? – Nikolai Shevchenko Jun 23 '22 at 13:06
  • @NikolaiShevchenko I'm not easily able to test a 'success followed by failure' scenario in a running application, unfortunately. As I understand `#result` is only available in the `unless` statement. – aSemy Jun 23 '22 at 13:24
  • Have you considered using Caffeine's [refreshAfterWrite](https://github.com/ben-manes/caffeine/wiki/Refresh) feature? If the entry passes the time threshold to be eligible for refresh, but has not expired yet, then the cache will perform an optimistic reload. If that reload succeeds then the entry is updated, whereas if it fails then the old value is retained. This might be out of scope for Spring Cache and you may need to use Caffeine's api. – Ben Manes Jun 23 '22 at 23:32

0 Answers0