This post series assumes familiarity with Kotlin, Java, and Spring Boot.
No AI was used during the writing of this post series.
What about errors?
In Part 1 we managed to reduce the load time of our endpoint from 2s to 1s, but we only covered the happy path. In this part, we'll see what happens when things go wrong.
As a reminder, here's our example:
@GetMapping("/{id}")
fun fetchBook(@PathVariable id: Long): Book = runBlocking(Dispatchers.IO) {
log.info("[START] $id")
val book = async { findBook(id) }
val reviews = async { fetchReviewsFor(id) }
book.await()
.copy(reviews = reviews.await())
.also { log.info("[SUCCESS] Returning ${it.id}") }
}
What if we can't fetch the reviews
The reviews service is a 3rd party service and we have no control over it. What happens if this service becomes unavailable or starts returning bad results? We can simulate that in our code and see what happens:
// application.properties
...
reviews-service.fail=true
...
And the calling the endpoint:
➜ ~ curl -s -w '\n\nTotal time: %{time_total}s\n' 'http://localhost:9999/books/3'
{"timestamp":"2026-03-29T06:33:41.178Z","status":500,"error":"Internal Server Error","trace":"java.lang.RuntimeException: Something went wrong when fetching reviews...","message":"Something went wrong when fetching reviews","path":"/books/3"}
Total time: 1.177979s
We get an error, and it still takes about 1s.
It kind of works, but what is worse than receiving an error?
Waiting for a long time and then receiving an error
We can simulate this situation as well:
// application.properties
books-repository.delay-in-seconds=3
reviews-service.delay-in-seconds=1
reviews-service.fail=true
Calling the endpoint:
➜ ~ curl -s -w '\n\nTotal time: %{time_total}s\n' 'http://localhost:9999/books/3'
{"timestamp":"2026-03-29T06:37:35.415Z","status":500,"error":"Internal Server Error","trace":"java.lang.RuntimeException: Something went wrong when fetching reviews...","message":"Something went wrong when fetching reviews","path":"/books/3"}
Total time: 3.179492s
We get the same error, but now after 3s :(
Why is this really bad?
The reviews service fails after one second, but we still wait for 2 more seconds for the book to be loaded from the database. Even after just one second, we know that we won't be able to return a success response, yet we still wait for 2 more seconds before returning an error.
How should it work?
It would make sense that as soon as one of the calls fails, we return an error to the client and cancel the other call. And reading through the documentation for coroutines, we see that this should happen by default:
Why doesn't it work exactly?
The problem in our example is that we are mixing blocking and non-blocking code. When fetchReviews fails it propagates the cancellation to the parent scope and then the parent scope tries to cancel all the other children (in this case findBook). But findBook is a blocking call that has no concept of "Cancellation", it cannot be cancelled, it can only be interrupted.
This is why we have to be especially careful when mixing blocking and non-blocking code. Here's our findBook method so far:
private fun findBook(id: Long) =
booksRepository.findById(id) ?: throw BookNotFoundException(id)
We can try to just add a suspend modifier to it, but that alone won't help much. What we also have to do is wrap it in runInterruptible:
private suspend fun findBook(id: Long) = runInterruptible {
booksRepository.findById(id) ?: throw BookNotFoundException(id)
}
To understand why, we can read about it here.
Did we fix it?
Calling the endpoint again:
➜ ~ curl -s -w '\n\nTotal time: %{time_total}s\n' 'http://localhost:9999/books/3'
{"timestamp":"2026-03-29T06:54:25.389Z","status":500,"error":"Internal Server Error","trace":"java.lang.RuntimeException: Something went wrong when fetching reviews...","message":"Something went wrong when fetching reviews","path":"/books/3"}
Total time: 1.176844s
We get the same exception, but now after only a second!
And if we check the logs:
08:54:24.347 ... BooksController - [START] 3
08:54:24.349 ... ReviewsService - [START] Fetching reviews for book with id 3
08:54:24.350 ... BooksRepository - [START] Fetching book with id 3
08:54:25.373 ... ERROR o.a.c.c.C.[.[.[.[dispatcherServlet] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.RuntimeException: Something went wrong when fetching reviews] ...
We see that we start fetching the book from the repository, but we don't see that we wait for it to finish, which means that it's successfully cancelled.
We of course need to do the same thing for fetchReviewsFor(id):
private suspend fun fetchReviewsFor(id: Long) = runInterruptible {
reviewsService.fetchReviewsFor(id)
}
After all these changes, here's how our code looks now:
@GetMapping("/{id}")
fun fetchBook(@PathVariable id: Long): Book = runBlocking(Dispatchers.IO) {
log.info("[START] $id")
val book = async { findBook(id) }
val reviews = async { fetchReviewsFor(id) }
book.await()
.copy(reviews = reviews.await())
.also { log.info("[SUCCESS] Returning ${it.id}") }
}
private suspend fun findBook(id: Long) = runInterruptible {
booksRepository.findById(id) ?: throw BookNotFoundException(id)
}
private suspend fun fetchReviewsFor(id: Long) = runInterruptible {
reviewsService.fetchReviewsFor(id)
}
Is that all?
If you read carefully, you noticed that I left out some parts of the logs (by replacing them with ...):
07:32:51.494 ... BooksController - [START] 5
07:32:51.494 ... BooksRepository - [START] Fetching book with id 5
07:32:52.501 ... BooksRepository - [DONE] Fetched book with id 5
07:32:52.502 ... ReviewsService - [START] Fetching reviews for book with id 5
07:32:53.506 ... ReviewsService - [DONE] Fetched reviews for book with id 5
07:32:53.506 ... BooksController - [SUCCESS] Returning 5
You can find out what's behind those 3 dots and why is it important in the next part.
Top comments (0)