Introduction
When Retrofit receives an HTTP error response (4xx or 5xx), the response.body() is null because the error body uses a different format than the success body. The actual error details are in response.errorBody(), but if not properly parsed, developers lose valuable debugging information and users see generic error messages instead of actionable feedback.
Symptoms
response.body()is null for 400/401/500 responses- Error message from server not accessible through Retrofit response
- Generic error shown to user instead of specific validation message
HttpExceptionthrown but error body not parsed- Success data class cannot represent error responses
Example of the issue:
``kotlin
try {
val response = api.login(request)
// response.body() is null for 401 Unauthorized
// Server sent: {"error": "Invalid credentials", "code": "AUTH_001"}
// But we cannot access it through response.body()
} catch (e: HttpException) {
// e.response().errorBody() contains the JSON string
// But it is not parsed into our error data class
}
Common Causes
- Error response body has different structure than success body
- Retrofit only deserializes body for 2xx responses
errorBody()returns raw string, not deserialized object- No error handling interceptor configured
- Try-catch catches exception but does not parse error body
Step-by-Step Fix
- 1.**Parse error body from HttpException":
- 2.```kotlin
- 3.data class LoginResponse(val token: String, val userId: String)
- 4.data class ApiError(val error: String, val code: String, val message: String)
suspend fun login(username: String, password: String): Result<LoginResponse> { return try { val response = api.login(LoginRequest(username, password)) if (response.isSuccessful && response.body() != null) { Result.success(response.body()!!) } else { // Parse error body val errorBody = response.errorBody()?.string() val apiError = parseErrorBody(errorBody) Result.failure(ApiException(apiError)) } } catch (e: HttpException) { val errorBody = e.response()?.errorBody()?.string() val apiError = parseErrorBody(errorBody) Result.failure(ApiException(apiError)) } catch (e: Exception) { Result.failure(e) } }
private fun parseErrorBody(errorBody: String?): ApiError { return try { val json = JSONObject(errorBody ?: "{}") ApiError( error = json.optString("error", "Unknown error"), code = json.optString("code", "UNKNOWN"), message = json.optString("message", "") ) } catch (e: Exception) { ApiError(error = "Unknown", code = "PARSE_ERROR", message = errorBody ?: "") } } ```
- 1.**Use Retrofit converter for automatic error body parsing":
- 2.```kotlin
- 3.// Create a custom CallAdapter that parses error bodies
- 4.class ErrorHandlingCallAdapterFactory : CallAdapter.Factory() {
- 5.override fun get(
- 6.returnType: Type,
- 7.annotations: Array<out Annotation>,
- 8.retrofit: Retrofit
- 9.): CallAdapter<*, *>? {
- 10.// Return adapter that wraps the call
- 11.return object : CallAdapter<Any, Call<Any>> {
- 12.override fun responseType() = returnType
- 13.override fun adapt(call: Call<Any>): Call<Any> {
- 14.return ErrorHandlingCall(call, retrofit)
- 15.}
- 16.}
- 17.}
- 18.}
class ErrorHandlingCall<T>( private val delegate: Call<T>, private val retrofit: Retrofit ) : Call<T> { override fun execute(): Response<T> { val response = delegate.execute() if (!response.isSuccessful) { val errorBody = response.errorBody()?.string() Log.e("API", "Error: $errorBody") } return response }
override fun enqueue(callback: Callback<T>) { delegate.enqueue(object : Callback<T> { override fun onResponse(call: Call<T>, response: Response<T>) { if (!response.isSuccessful) { val errorBody = response.errorBody()?.string() Log.e("API", "Error: $errorBody") } callback.onResponse(call, response) } override fun onFailure(call: Call<T>, t: Throwable) { callback.onFailure(call, t) } }) } } ```
- 1.**Use sealed class response wrapper":
- 2.```kotlin
- 3.sealed class ApiResponse<out T> {
- 4.data class Success<T>(val data: T) : ApiResponse<T>()
- 5.data class Error(val code: String, val message: String) : ApiResponse<Nothing>()
- 6.data class NetworkError(val exception: IOException) : ApiResponse<Nothing>()
- 7.}
suspend fun <T> safeApiCall(call: suspend () -> Response<T>): ApiResponse<T> { return try { val response = call() if (response.isSuccessful) { ApiResponse.Success(response.body()!!) } else { val errorBody = response.errorBody()?.string() val error = try { Json.decodeFromString<ApiError>(errorBody ?: "{}") } catch (e: Exception) { ApiError("Unknown", "PARSE_ERROR", errorBody ?: "") } ApiResponse.Error(error.code, error.message) } } catch (e: IOException) { ApiResponse.NetworkError(e) } }
// Usage when (val result = safeApiCall { api.login(request) }) { is ApiResponse.Success -> showHome(result.data) is ApiResponse.Error -> showError(result.message) is ApiResponse.NetworkError -> showOfflineMessage() } ```
Prevention
- Always check
response.isSuccessfulbefore accessingresponse.body() - Parse
errorBody()for meaningful error messages - Use a response wrapper sealed class for type-safe error handling
- Log error bodies in debug builds for troubleshooting
- Add OkHttp logging interceptor to see raw request/response
- Write tests that verify error body parsing for each API endpoint