Introduction
Retrofit's Response<T> wrapper returns body() as null in several scenarios beyond just HTTP errors: empty response bodies, mismatched content types, or 204 No Content responses. When code assumes a non-null body on successful requests (response.isSuccessful), it crashes with NullPointerException or fails to handle the edge case properly.
Symptoms
response.body()is null even thoughresponse.isSuccessfulis trueNullPointerExceptionwhen accessingresponse.body()!!- 204 No Content returns
body() = nullas expected but code does not handle it - Server returns
Content-Type: text/plainbut Retrofit expects JSON - DELETE and PATCH endpoints return null bodies
Example problem: ```kotlin @DELETE("users/{id}") suspend fun deleteUser(@Path("id") id: String): Response<User>
// In calling code: val response = api.deleteUser("123") if (response.isSuccessful) { val user = response.body()!! // CRASH: body is null for 204 response updateUI(user) } ```
Common Causes
- HTTP 204 No Content has no body by definition
- Server returns 200 OK with empty body
{}or"" - Content-Type header does not match the converter factory
- Response type declared as non-nullable when API can return null
- Error responses return error body but
body()is still null
Step-by-Step Fix
- 1.Handle nullable response body properly:
- 2.```kotlin
- 3.suspend fun deleteUser(userId: String): Result<Unit> {
- 4.return try {
- 5.val response = api.deleteUser(userId)
- 6.if (response.isSuccessful) {
- 7.Result.success(Unit) // No body expected
- 8.} else {
- 9.Result.failure(HttpException(response))
- 10.}
- 11.} catch (e: Exception) {
- 12.Result.failure(e)
- 13.}
- 14.}
- 15.
` - 16.Use Unit for endpoints that do not return a body:
- 17.```kotlin
- 18.// Correct API definition for no-body responses
- 19.@DELETE("users/{id}")
- 20.suspend fun deleteUser(@Path("id") id: String): Response<Unit>
@POST("users/{id}/activate") suspend fun activateUser(@Path("id") id: String): Response<Void>
// Calling code: val response = api.deleteUser("123") if (response.isSuccessful) { // No need to access body showSuccessMessage() } else { showError(response.message()) } ```
- 1.Parse error body when response fails:
- 2.```kotlin
- 3.suspend fun safeApiCall(): Result<UserData> {
- 4.return try {
- 5.val response = api.getUser()
- 6.if (response.isSuccessful) {
- 7.Result.success(response.body()!!)
- 8.} else {
- 9.// Parse error body
- 10.val errorBody = response.errorBody()?.string()
- 11.val errorResponse = try {
- 12.Gson().fromJson(errorBody, ErrorResponse::class.java)
- 13.} catch (e: Exception) {
- 14.ErrorResponse(message = "Unknown error")
- 15.}
- 16.Result.failure(ApiException(errorResponse.message, response.code()))
- 17.}
- 18.} catch (e: IOException) {
- 19.Result.failure(NetworkException(e))
- 20.}
- 21.}
- 22.
` - 23.Use a sealed class for API results:
- 24.```kotlin
- 25.sealed class ApiResult<out T> {
- 26.data class Success<T>(val data: T) : ApiResult<T>()
- 27.data class Error(val code: Int, val message: String) : ApiResult<Nothing>()
- 28.data class NetworkError(val exception: IOException) : ApiResult<Nothing>()
- 29.}
suspend fun <T : Any> safeCall(call: suspend () -> Response<T>): ApiResult<T> { return try { val response = call() if (response.isSuccessful) { val body = response.body() if (body != null) { ApiResult.Success(body) } else { ApiResult.Success(Unit as T) } } else { ApiResult.Error(response.code(), response.message()) } } catch (e: IOException) { ApiResult.NetworkError(e) } } ```
Prevention
- Declare return type as
Response<Unit>for endpoints without response bodies - Never use
response.body()!!without checkingresponse.isSuccessful - Use a result wrapper that handles success, error, and network failure cases
- Add interceptors that log request/response bodies for debugging
- Write integration tests against the actual API, not just mocks
- Use Moshi or Gson with null-safe types in your data classes