DEV Community

Cover image for Structured Concurrency in Practice: CoroutineScope vs StructuredTaskScope [Part 2]
Filip Egeric
Filip Egeric

Posted on

Structured Concurrency in Practice: CoroutineScope vs StructuredTaskScope [Part 2]

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}") }
}
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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] ...
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

You can find out what's behind those 3 dots and why is it important in the next part.

Top comments (0)