Why Reading the Response Body in an OkHttp Interceptor Throws IllegalStateException: closed

An OkHttp interceptor that calls response.body.string() and then returns the same response causes IllegalStateException: closed one step later. The crash comes from the next reader, usually Retrofit’s converter or your own code. Two causes stack here. First, string() is a terminal read. It pulls the whole body into memory and then closes the one-shot source behind it. Second, the interceptor hands back the original Response object. The caller reads from the same source the interceptor just closed. Okio checks for that on every read and throws the bare word “closed”. Rebuilding the body from the string stops the crash. It also buffers every response, file downloads included, in memory. The fix is response.peekBody(limit). It reads a bounded copy and leaves the original body unread. Apply it only to JSON responses, so streaming bodies pass through untouched.

A session check that breaks every API call

The running example is a feed app. Its backend reports an expired session with HTTP 200 and an error field in the JSON. So a developer adds an application interceptor that looks for "session_expired" and logs the user out.

class SessionExpiredInterceptor(private val onExpired: () -> Unit) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val response = chain.proceed(chain.request())
        val json = response.body.string()          // reads AND closes the body
        if ("\"session_expired\"" in json) onExpired()
        return response                            // hands back a closed body
    }
}

val client = OkHttpClient.Builder()
    .addInterceptor(SessionExpiredInterceptor { sessionManager.logout() })
    .build()

We ran this against a MockWebServer on OkHttp 5.5.0 with Kotlin 2.4.20. The server returned {"items":[1,2,3]}. The caller read the body after execute(). The status line came through fine. The body read failed.

code=200
java.lang.IllegalStateException: closed

The same snippet on OkHttp 4.12.0, with body!! in place of body, threw the same exception. In a Retrofit app, the reader is the converter. A Chucker issue filed in January 2020 shows that trace. GsonResponseBodyConverter.convert fails inside okio.RealBufferedSource.read because an earlier interceptor had closed the body.

string() reads the whole body and then closes it

The ResponseBody source in OkHttp 5.5.0 calls the class a one-shot stream from the server. A live socket backs it, or an open file for cached responses. Its KDoc lists string() and bytes() among the calls that close the body. The implementation shows why. string() wraps source() in Kotlin’s use block. It reads the bytes into a String, then use closes the source.

So string() consumes the body and releases it. No second copy is left for a later reader. Once it returns, the bytes exist only in the String the interceptor kept.

Why the next reader throws IllegalStateException: closed

The second cause is the return response line. chain.proceed() gives the interceptor a Response. The interceptor passes that same object up the chain. Nothing in between copies the body. The caller’s response.body is the body the interceptor just closed.

The caller’s first read hits Okio’s RealBufferedSource. Its read methods start with a check(!closed) whose message is just “closed”. That’s why the exception carries one word and no hint about the interceptor.

The two causes need each other. A string() call with nothing reading afterward is fine. Returning the response without reading it is fine too. The crash needs a terminal read in one layer and a second reader in another. The bug also turns up in debugging tools that add their own interceptor. A Flipper issue from April 2022 reports its network interceptor throwing the same exception. Another interceptor earlier in the chain had closed the response.

Rebuilding the body from the string buffers every download

The common first fix keeps the String and wraps it in a new body.

val json = response.body.string()
if ("\"session_expired\"" in json) onExpired()
return response.newBuilder()
    .body(json.toResponseBody(response.body.contentType()))
    .build()

It works. In our run, the caller read all 40,013 characters of a large JSON body. The cost shows up on every other response. This interceptor runs for every call on the client, including image and file downloads. The ResponseBody KDoc warns that string() loads the entire body into memory. It says a very large body may trigger an OutOfMemoryError. A download that used to stream to disk now sits in the heap as a decoded String first.

Peek a bounded copy, and only for JSON responses

private const val MAX_PEEK = 16L * 1024

class SessionExpiredInterceptor(private val onExpired: () -> Unit) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val response = chain.proceed(chain.request())
        if (response.body.contentType()?.subtype != "json") return response
        val head = response.peekBody(MAX_PEEK).string() // copy, original stays unread
        if ("\"session_expired\"" in head) onExpired()
        return response
    }
}

Response.peekBody calls peek() on the body’s source. It copies up to byteCount bytes into a separate buffer and returns that buffer as a new ResponseBody. The original source keeps its bytes. Calling string() on the copy closes only the copy. The KDoc warns that the peeked bytes are loaded into memory. It suggests a modest limit. It also says calling peekBody after the body is consumed is an error. So the order inside the interceptor matters.

We sent three responses through the fixed client. They were a normal feed, a session error and a 100,000-byte application/octet-stream file.

/feed code=200 length=17 start={"items":[1,2,3]}
logging out
/feed code=200 length=27 start={"error":"session_expired"}
/file code=200 length=100000 start=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

The file skipped the peek because of the content type check. A separate run sent a 40,013-byte JSON body. The peek stopped at 16,384 bytes. The caller still read all 40,013. The cap only limits what the interceptor sees. So it works when the error field sits near the start of a small error body. If the error could appear past the cap, raise the cap for that endpoint rather than dropping it.

HttpLoggingInterceptor avoids the crash in a similar way. In its 5.5.0 source, the BODY level calls source.request(Long.MAX_VALUE) to buffer the entire body. Then it logs from buffer.clone(). The caller still gets an unread source. That costs memory for every logged body, the same tradeoff as the rebuild fix.

If the backend can return 401 for an expired session, an OkHttp Authenticator handles it instead. Its KDoc describes reactive authentication after a challenge from the server. No interceptor has to read the body then.

Walking the interviewer from the crash to the fix

This snippet fits the debugging round, where you get working code with one planted failure. Hass explains what the debugging round scores after the first fix. A strong answer goes in this order.

  1. Name the reader that fails. The status code arrives. Then the first body read throws, so something closed the body between the network and the caller.
  2. Give the first cause. string() is a one-shot read that closes the source when it finishes.
  3. Give the second cause. The interceptor returns the same Response, so the caller reads the closed source. Okio’s check(!closed) produces the message.
  4. Offer the rebuild fix, then name its cost yourself. It buffers every body on the client, downloads included.
  5. Land on peekBody with a cap and a content type check. Then ask whether the server can send 401, so an Authenticator can replace the body check entirely.

Fixes that stop the crash and leave a bug

  • “Store the string in a variable and reuse it.” That works when one callback reads the body twice. In an interceptor it doesn’t help, because the second reader is Retrofit’s converter. It never sees your variable.
  • “Check source().isOpen before reading.” That’s the fix the Flipper report proposed for its own interceptor. It stops that one interceptor from crashing. That interceptor then sees no body. Any reader after it still fails.
  • “Use peekBody(Long.MAX_VALUE).” It stops the crash. It also copies the full body for every call, the same memory cost as the rebuild fix.
  • “Move it to addNetworkInterceptor().” The body is still one-shot there. The OkHttpClient KDoc says a network interceptor observes a single network request and response. A call that follows a redirect makes more than one, so the logout check could run twice.
  • “Read byteStream() instead of string().” Every read consumes the same source. Once the interceptor has read the bytes, the caller can’t read them again.

Another converter-step bug is why Gson returns null fields only in a release build. There, R8 strips what Gson needs.

Leave a Comment