{"id":746,"date":"2026-10-04T13:40:00","date_gmt":"2026-10-04T11:40:00","guid":{"rendered":"https:\/\/grindloop.io\/blog\/?p=746"},"modified":"2026-10-03T13:39:32","modified_gmt":"2026-10-03T11:39:32","slug":"okhttp-interceptor-illegalstateexception-closed","status":"publish","type":"post","link":"https:\/\/grindloop.ai\/blog\/okhttp-interceptor-illegalstateexception-closed\/","title":{"rendered":"Why Reading the Response Body in an OkHttp Interceptor Throws IllegalStateException: closed"},"content":{"rendered":"<p>An OkHttp interceptor that calls <code>response.body.string()<\/code> and then returns the same response causes <code>IllegalStateException: closed<\/code> one step later. The crash comes from the next reader, usually Retrofit&#8217;s converter or your own code. Two causes stack here. First, <code>string()<\/code> 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 <code>Response<\/code> object. The caller reads from the same source the interceptor just closed. Okio checks for that on every read and throws the bare word &#8220;closed&#8221;. Rebuilding the body from the string stops the crash. It also buffers every response, file downloads included, in memory. The fix is <code>response.peekBody(limit)<\/code>. It reads a bounded copy and leaves the original body unread. Apply it only to JSON responses, so streaming bodies pass through untouched.<\/p>\n\n<h2>A session check that breaks every API call<\/h2>\n\n<p>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 <code>\"session_expired\"<\/code> and logs the user out.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">class SessionExpiredInterceptor(private val onExpired: () -&gt; Unit) : Interceptor {\n    override fun intercept(chain: Interceptor.Chain): Response {\n        val response = chain.proceed(chain.request())\n        val json = response.body.string()          \/\/ reads AND closes the body\n        if (\"\\\"session_expired\\\"\" in json) onExpired()\n        return response                            \/\/ hands back a closed body\n    }\n}\n\nval client = OkHttpClient.Builder()\n    .addInterceptor(SessionExpiredInterceptor { sessionManager.logout() })\n    .build()<\/pre>\n\n\n<p>We ran this against a MockWebServer on OkHttp 5.5.0 with Kotlin 2.4.20. The server returned <code>{\"items\":[1,2,3]}<\/code>. The caller read the body after <code>execute()<\/code>. The status line came through fine. The body read failed.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">code=200\njava.lang.IllegalStateException: closed<\/pre>\n\n\n<p>The same snippet on OkHttp 4.12.0, with <code>body!!<\/code> in place of <code>body<\/code>, threw the same exception. In a Retrofit app, the reader is the converter. A <a href=\"https:\/\/github.com\/ChuckerTeam\/chucker\/issues\/192\" target=\"_blank\" rel=\"noopener\">Chucker issue filed in January 2020<\/a> shows that trace. <code>GsonResponseBodyConverter.convert<\/code> fails inside <code>okio.RealBufferedSource.read<\/code> because an earlier interceptor had closed the body.<\/p>\n\n<h2>string() reads the whole body and then closes it<\/h2>\n\n<p>The <a href=\"https:\/\/github.com\/square\/okhttp\/blob\/parent-5.5.0\/okhttp\/src\/commonJvmAndroid\/kotlin\/okhttp3\/ResponseBody.kt\" target=\"_blank\" rel=\"noopener\"><code>ResponseBody<\/code> source in OkHttp 5.5.0<\/a> 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 <code>string()<\/code> and <code>bytes()<\/code> among the calls that close the body. The implementation shows why. <code>string()<\/code> wraps <code>source()<\/code> in Kotlin&#8217;s <code>use<\/code> block. It reads the bytes into a <code>String<\/code>, then <code>use<\/code> closes the source.<\/p>\n\n<p>So <code>string()<\/code> consumes the body and releases it. No second copy is left for a later reader. Once it returns, the bytes exist only in the <code>String<\/code> the interceptor kept.<\/p>\n\n<h2>Why the next reader throws IllegalStateException: closed<\/h2>\n\n<p>The second cause is the <code>return response<\/code> line. <code>chain.proceed()<\/code> gives the interceptor a <code>Response<\/code>. The interceptor passes that same object up the chain. Nothing in between copies the body. The caller&#8217;s <code>response.body<\/code> is the body the interceptor just closed.<\/p>\n\n<p>The caller&#8217;s first read hits Okio&#8217;s <a href=\"https:\/\/github.com\/square\/okio\/blob\/parent-3.18.1\/okio\/src\/commonMain\/kotlin\/okio\/internal\/RealBufferedSource.kt\" target=\"_blank\" rel=\"noopener\"><code>RealBufferedSource<\/code><\/a>. Its read methods start with a <code>check(!closed)<\/code> whose message is just &#8220;closed&#8221;. That&#8217;s why the exception carries one word and no hint about the interceptor.<\/p>\n\n<p>The two causes need each other. A <code>string()<\/code> 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 <a href=\"https:\/\/github.com\/facebook\/flipper\/issues\/3663\" target=\"_blank\" rel=\"noopener\">Flipper issue from April 2022<\/a> reports its network interceptor throwing the same exception. Another interceptor earlier in the chain had closed the response.<\/p>\n\n<h2>Rebuilding the body from the string buffers every download<\/h2>\n\n<p>The common first fix keeps the <code>String<\/code> and wraps it in a new body.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">val json = response.body.string()\nif (\"\\\"session_expired\\\"\" in json) onExpired()\nreturn response.newBuilder()\n    .body(json.toResponseBody(response.body.contentType()))\n    .build()<\/pre>\n\n\n<p>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 <code>ResponseBody<\/code> KDoc warns that <code>string()<\/code> loads the entire body into memory. It says a very large body may trigger an <code>OutOfMemoryError<\/code>. A download that used to stream to disk now sits in the heap as a decoded <code>String<\/code> first.<\/p>\n\n<h2>Peek a bounded copy, and only for JSON responses<\/h2>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">private const val MAX_PEEK = 16L * 1024\n\nclass SessionExpiredInterceptor(private val onExpired: () -&gt; Unit) : Interceptor {\n    override fun intercept(chain: Interceptor.Chain): Response {\n        val response = chain.proceed(chain.request())\n        if (response.body.contentType()?.subtype != \"json\") return response\n        val head = response.peekBody(MAX_PEEK).string() \/\/ copy, original stays unread\n        if (\"\\\"session_expired\\\"\" in head) onExpired()\n        return response\n    }\n}<\/pre>\n\n\n<p><a href=\"https:\/\/github.com\/square\/okhttp\/blob\/parent-5.5.0\/okhttp\/src\/commonJvmAndroid\/kotlin\/okhttp3\/Response.kt\" target=\"_blank\" rel=\"noopener\"><code>Response.peekBody<\/code><\/a> calls <code>peek()<\/code> on the body&#8217;s source. It copies up to <code>byteCount<\/code> bytes into a separate buffer and returns that buffer as a new <code>ResponseBody<\/code>. The original source keeps its bytes. Calling <code>string()<\/code> 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 <code>peekBody<\/code> after the body is consumed is an error. So the order inside the interceptor matters.<\/p>\n\n<p>We sent three responses through the fixed client. They were a normal feed, a session error and a 100,000-byte <code>application\/octet-stream<\/code> file.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">\/feed code=200 length=17 start={\"items\":[1,2,3]}\nlogging out\n\/feed code=200 length=27 start={\"error\":\"session_expired\"}\n\/file code=200 length=100000 start=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx<\/pre>\n\n\n<p>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.<\/p>\n\n<p><code>HttpLoggingInterceptor<\/code> avoids the crash in a similar way. In <a href=\"https:\/\/github.com\/square\/okhttp\/blob\/parent-5.5.0\/okhttp-logging-interceptor\/src\/main\/kotlin\/okhttp3\/logging\/HttpLoggingInterceptor.kt\" target=\"_blank\" rel=\"noopener\">its 5.5.0 source<\/a>, the <code>BODY<\/code> level calls <code>source.request(Long.MAX_VALUE)<\/code> to buffer the entire body. Then it logs from <code>buffer.clone()<\/code>. The caller still gets an unread source. That costs memory for every logged body, the same tradeoff as the rebuild fix.<\/p>\n\n<p>If the backend can return 401 for an expired session, an OkHttp <a href=\"https:\/\/github.com\/square\/okhttp\/blob\/parent-5.5.0\/okhttp\/src\/commonJvmAndroid\/kotlin\/okhttp3\/Authenticator.kt\" target=\"_blank\" rel=\"noopener\"><code>Authenticator<\/code><\/a> handles it instead. Its KDoc describes reactive authentication after a challenge from the server. No interceptor has to read the body then.<\/p>\n\n<h2>Walking the interviewer from the crash to the fix<\/h2>\n\n<p>This snippet fits the debugging round, where you get working code with one planted failure. Hass explains <a href=\"https:\/\/grindloop.ai\/blog\/android-debugging-interview-second-bug\/\">what the debugging round scores after the first fix<\/a>. A strong answer goes in this order.<\/p>\n\n<ol>\n<li>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.<\/li>\n<li>Give the first cause. <code>string()<\/code> is a one-shot read that closes the source when it finishes.<\/li>\n<li>Give the second cause. The interceptor returns the same <code>Response<\/code>, so the caller reads the closed source. Okio&#8217;s <code>check(!closed)<\/code> produces the message.<\/li>\n<li>Offer the rebuild fix, then name its cost yourself. It buffers every body on the client, downloads included.<\/li>\n<li>Land on <code>peekBody<\/code> with a cap and a content type check. Then ask whether the server can send 401, so an <code>Authenticator<\/code> can replace the body check entirely.<\/li>\n<\/ol>\n\n<h2>Fixes that stop the crash and leave a bug<\/h2>\n\n<ul>\n<li>&#8220;Store the string in a variable and reuse it.&#8221; That works when one callback reads the body twice. In an interceptor it doesn&#8217;t help, because the second reader is Retrofit&#8217;s converter. It never sees your variable.<\/li>\n<li>&#8220;Check <code>source().isOpen<\/code> before reading.&#8221; That&#8217;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.<\/li>\n<li>&#8220;Use <code>peekBody(Long.MAX_VALUE)<\/code>.&#8221; It stops the crash. It also copies the full body for every call, the same memory cost as the rebuild fix.<\/li>\n<li>&#8220;Move it to <code>addNetworkInterceptor()<\/code>.&#8221; The body is still one-shot there. The <a href=\"https:\/\/github.com\/square\/okhttp\/blob\/parent-5.5.0\/okhttp\/src\/commonJvmAndroid\/kotlin\/okhttp3\/OkHttpClient.kt\" target=\"_blank\" rel=\"noopener\"><code>OkHttpClient<\/code> KDoc<\/a> 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.<\/li>\n<li>&#8220;Read <code>byteStream()<\/code> instead of <code>string()<\/code>.&#8221; Every read consumes the same source. Once the interceptor has read the bytes, the caller can&#8217;t read them again.<\/li>\n<\/ul>\n\n<p>Another converter-step bug is <a href=\"https:\/\/grindloop.ai\/blog\/gson-null-fields-release-build\/\">why Gson returns null fields only in a release build<\/a>. There, R8 strips what Gson needs.<\/p>","protected":false},"excerpt":{"rendered":"<p>Reading the response body in an OkHttp interceptor causes IllegalStateException: closed. The two stacked causes, the peekBody fix and the interview answer.<\/p>\n","protected":false},"author":2,"featured_media":748,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Why Reading the Response Body in an OkHttp Interceptor Throws IllegalStateException: closed","rank_math_description":"Reading the response body in an OkHttp interceptor causes IllegalStateException: closed. The two stacked causes, the peekBody fix and the interview answer.","rank_math_focus_keyword":"IllegalStateException: closed","footnotes":""},"categories":[9,6],"tags":[15,16,12,57,95,45],"class_list":["post-746","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-bug-squash","category-networking","tag-android","tag-interview-prep","tag-kotlin","tag-networking","tag-okhttp","tag-technical-interview"],"_links":{"self":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/746","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/comments?post=746"}],"version-history":[{"count":1,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/746\/revisions"}],"predecessor-version":[{"id":747,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/746\/revisions\/747"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media\/748"}],"wp:attachment":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media?parent=746"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/categories?post=746"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/tags?post=746"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}