Explore internal mechanisms of Retrofit, and how it works

skydovesJaewoong Eum (skydoves)||29 min read

Explore internal mechanisms of Retrofit, and how it works

Making REST API calls has been a fundamental requirement in Android development, yet the complexity of managing HTTP requests, serialization, error handling, and thread management has long been a persistent challenge. Retrofit emerged as Square's solution to this problem, transforming a verbose, error-prone process into an elegant, annotation-driven API. But the real power of Retrofit isn't just its simplified interface, it's the sophisticated machinery working behind the scenes to turn interface methods into HTTP calls.

In this article, you'll dive deep into the internal mechanisms of Retrofit, exploring how Java's dynamic proxies create implementation classes at runtime, how annotations are parsed and cached using sophisticated locking strategies, how the framework transforms method calls into OkHttp requests through a layered architecture, and the subtle optimizations that make it production-ready. This isn't a beginner's guide to using Retrofit, it's a deep dive into how Retrofit actually works under the hood.

Understanding the core abstraction: What makes Retrofit special

At its heart, Retrofit is a type-safe HTTP client that uses dynamic proxies and annotation processing to convert interface method declarations into HTTP requests. What distinguishes Retrofit from manual HTTP clients is its adherence to two fundamental principles: declarative API definition and pluggable architecture.

The declarative API definition means you don't manually construct HTTP requests for every endpoint. Instead, Retrofit provides annotations that describe the request:

interface GitHubApi {
    @GET("users/{user}/repos")
    fun listRepos(@Path("user") user: String): Call<List<Repo>>
}

// Implementation generated automatically:
val api = retrofit.create<GitHubApi>()
val call = api.listRepos("octocat")

The pluggable architecture means Retrofit separates concerns through factory patterns. Every aspect of request/response handling is customizable:

  • CallAdapter: Transforms Call<T> into other types (RxJava Observable, Kotlin suspend fun, Java 8 CompletableFuture)
  • Converter: Serializes/deserializes request/response bodies (Gson, Jackson, Moshi, Protobuf)
  • Call.Factory: Creates HTTP calls (typically OkHttp, but swappable)

These properties aren't just conveniences, they're architectural constraints that enable compile-time type safety and runtime flexibility. The dynamic proxy mechanism allows Retrofit to parse annotations once per method and cache the parsing logic, making subsequent calls extremely fast. The factory chains allow you to add Gson JSON parsing or RxJava integration without modifying any core Retrofit code.

The dynamic proxy pattern: How Retrofit creates implementations

When you call retrofit.create(MyApi.class), you're not getting a manually written implementation. You're getting a JDK dynamic proxy that intercepts every method call at runtime. This is the foundation of Retrofit's "magic."

Proxy creation in Retrofit.create()

Let's examine the actual proxy creation code in the Retrofit class:

@SuppressWarnings("unchecked")
public <T> T create(final Class<T> service) {
  validateServiceInterface(service);
  return (T) Proxy.newProxyInstance(
      service.getClassLoader(),
      new Class<?>[] {service},
      new InvocationHandler() {
        private final Object[] emptyArgs = new Object[0];

        @Override
        public @Nullable Object invoke(Object proxy, Method method, @Nullable Object[] args)
            throws Throwable {
          // If the method is a method from Object then defer to normal invocation.
          if (method.getDeclaringClass() == Object.class) {
            return method.invoke(this, args);
          }
          args = args != null ? args : emptyArgs;
          Reflection reflection = Platform.reflection;
          return reflection.isDefaultMethod(method)
              ? reflection.invokeDefaultMethod(method, service, proxy, args)
              : loadServiceMethod(service, method).invoke(proxy, args);
        }
      });
}

This code uses Java's Proxy.newProxyInstance() to generate a class at runtime that implements your interface. Every method call goes through the InvocationHandler.invoke() method, which has three dispatch paths:

1. Object methods: Methods like equals(), hashCode(), and toString() are delegated to the handler itself:

if (method.getDeclaringClass() == Object.class) {
  return method.invoke(this, args);
}

This ensures that basic Java object operations work correctly on the proxy instance.

2. Default methods (Java 8+) - Interface default methods are invoked using platform-specific reflection:

return reflection.invokeDefaultMethod(method, service, proxy, args);

On Java 8+, Retrofit uses MethodHandle to invoke default methods. This allows you to add helper methods to your API interfaces without Retrofit trying to parse them as HTTP endpoints.

3. Retrofit methods: Everything else is treated as an HTTP endpoint:

return loadServiceMethod(service, method).invoke(proxy, args);

This is where the real work happens. The loadServiceMethod() call parses annotations and caches the result, then invoke() executes the HTTP request.

Interface validation

Before creating the proxy, Retrofit validates the interface with some strict rules in the Retrofit class:

private void validateServiceInterface(Class<?> service) {
  if (!service.isInterface()) {
    throw new IllegalArgumentException("API declarations must be interfaces.");
  }

  Deque<Class<?>> check = new ArrayDeque<>(1);
  check.add(service);
  while (!check.isEmpty()) {
    Class<?> candidate = check.removeFirst();
    if (candidate.getTypeParameters().length != 0) {
      StringBuilder message =
          new StringBuilder("Type parameters are unsupported on ").append(candidate.getName());
      if (candidate != service) {
        message.append(" which is an interface of ").append(service.getName());
      }
      throw new IllegalArgumentException(message.toString());
    }
    Collections.addAll(check, candidate.getInterfaces());
  }
  // ...
}

This validation enforces two critical constraints:

  1. Must be an interface: Classes can't be proxied by JDK proxies (they'd need CGLIB or ByteBuddy)
  2. No generic type parameters: interface Api<T> is forbidden because generics are erased at runtime

The breadth-first search through the interface hierarchy ensures that even inherited interfaces don't violate these rules.

The performance benefit of proxies

Why use dynamic proxies instead of annotation processing to generate implementation classes at compile time? The answer is flexibility. Proxies allow Retrofit to:

  • Parse annotations lazily (only when methods are first called)
  • Support different return types through the CallAdapter mechanism
  • Avoid compile-time code generation complexity

The trade-off is a slight runtime overhead for the first method call (annotation parsing), but this is amortized through aggressive caching.

The service method cache: lazy initialization

One of Retrofit's most elegant optimizations is how it caches parsed annotations. The serviceMethodCache in the Retrofit class uses a three-state state machine to handle concurrent parsing.

The three-state cache design

/**
 * Method associations in this map will be in one of three states:
 * <ol>
 *   <li>No value - no one has started parsing annotations for the method.</li>
 *   <li>Lock object - a thread has started parsing. Once available, the map
 *       will have been updated with the parsed model.</li>
 *   <li>{@code ServiceMethod} - annotations have been fully parsed.</li>
 * </ol>
 */
private final ConcurrentHashMap<Method, Object> serviceMethodCache = new ConcurrentHashMap<>();

The clever insight here is using the map values themselves to track parsing state:

  • State 1: No entry → Available for parsing
  • State 2: Lock object → Another thread is parsing, wait for it
  • State 3: ServiceMethod → Parsing complete, use it

This avoids separate state tracking and leverages ConcurrentHashMap's atomic operations.

The loadServiceMethod implementation

Let's examine the full parsing logic from the internal codes:

ServiceMethod<?> loadServiceMethod(Class<?> service, Method method) {
  while (true) {
    Object lookup = serviceMethodCache.get(method);

    if (lookup instanceof ServiceMethod<?>) {
      // Happy path: method is already parsed into the model.
      return (ServiceMethod<?>) lookup;
    }

    if (lookup == null) {
      // Map does not contain any value. Try to put in a lock for this method.
      // We MUST synchronize on the lock before it is visible to others.
      Object lock = new Object();
      synchronized (lock) {
        lookup = serviceMethodCache.putIfAbsent(method, lock);
        if (lookup == null) {
          // On successful lock insertion, perform the work and update the map.
          ServiceMethod<Object> result;
          try {
            result = ServiceMethod.parseAnnotations(this, service, method);
          } catch (Throwable e) {
            // Remove the lock on failure. Other threads will retry.
            serviceMethodCache.remove(method);
            throw e;
          }
          serviceMethodCache.put(method, result);
          return result;
        }
      }
    }

    // Either the initial lookup or putIfAbsent returned someone else's lock.
    // Wait for them to finish parsing.
    synchronized (lookup) {
      Object result = serviceMethodCache.get(method);
      if (result == null) {
        // The other thread failed. Retry (and probably also fail).
        continue;
      }
      return (ServiceMethod<?>) result;
    }
  }
}

This is a sophisticated lock-free fast path with a synchronized slow path. Let's trace through the execution for different scenarios:

Scenario 1: First call to a method (no cache entry)

  1. lookup = serviceMethodCache.get(method)null
  2. Create lock object and synchronize on it
  3. putIfAbsent(method, lock)null (success)
  4. Parse annotations while holding the lock
  5. Store result in cache, replacing the lock object
  6. Return the parsed ServiceMethod

Scenario 2: Concurrent first calls to the same method

Thread A:

  1. Gets null, creates lockA, synchronizes on it
  2. putIfAbsent(method, lockA)null (success)
  3. Starts parsing annotations...

Thread B (runs during A's parsing):

  1. Gets lockA from the cache
  2. Tries to synchronize on lockA
  3. Blocks waiting for A to finish
  4. Once A completes, B acquires lock
  5. Gets ServiceMethod from cache
  6. Returns it

Scenario 3: Subsequent calls (cache hit)

  1. lookup = serviceMethodCache.get(method)ServiceMethod
  2. lookup instanceof ServiceMethod<?>true
  3. Return immediately (no synchronization needed)

The beauty of this design is that the happy path is lock-free. Once a method is parsed, all subsequent calls just do a ConcurrentHashMap.get() and return. The volatile semantics of ConcurrentHashMap ensure visibility across threads.

Error handling and retry logic

Notice the error handling in the Retrofit class:

try {
  result = ServiceMethod.parseAnnotations(this, service, method);
} catch (Throwable e) {
  // Remove the lock on failure. Other threads will retry.
  serviceMethodCache.remove(method);
  throw e;
}

If annotation parsing fails (e.g., due to invalid annotations), the lock is removed from the cache. This allows other threads, or even the same thread on retry, to attempt parsing again. This is important for debugging: you can fix your annotation and call the method again without restarting the app.

Annotation parsing: From metadata to executable logic

Now let's examine how Retrofit parses annotations. This happens in two phases: method-level parsing (HTTP verb, URL, headers) and parameter-level parsing (path parameters, query parameters, body).

The RequestFactory builder

The entry point is RequestFactory.parseAnnotations() in the RequestFactory class, which uses a builder pattern:

static RequestFactory parseAnnotations(Retrofit retrofit, Class<?> service, Method method) {
  return new Builder(retrofit, service, method).build();
}

The Builder.build() method orchestrates the parsing:

RequestFactory build() {
  for (Annotation annotation : methodAnnotations) {
    parseMethodAnnotation(annotation);
  }

  if (httpMethod == null) {
    throw methodError(method, "HTTP method annotation is required (e.g., @GET, @POST, etc.).");
  }

  // Validation logic...

  int parameterCount = parameterAnnotationsArray.length;
  parameterHandlers = new ParameterHandler<?>[parameterCount];
  for (int p = 0, lastParameter = parameterCount - 1; p < parameterCount; p++) {
    parameterHandlers[p] =
        parseParameter(p, parameterTypes[p], parameterAnnotationsArray[p], p == lastParameter);
  }

  // More validation...

  return new RequestFactory(this);
}

This creates an immutable RequestFactory that contains all the metadata needed to construct requests.

Method annotation parsing

Let's examine how HTTP verbs are parsed in the RequestFactory class:

private void parseMethodAnnotation(Annotation annotation) {
  if (annotation instanceof DELETE) {
    parseHttpMethodAndPath("DELETE", ((DELETE) annotation).value(), false);
  } else if (annotation instanceof GET) {
    parseHttpMethodAndPath("GET", ((GET) annotation).value(), false);
  } else if (annotation instanceof HEAD) {
    parseHttpMethodAndPath("HEAD", ((HEAD) annotation).value(), false);
  } else if (annotation instanceof PATCH) {
    parseHttpMethodAndPath("PATCH", ((PATCH) annotation).value(), true);
  } else if (annotation instanceof POST) {
    parseHttpMethodAndPath("POST", ((POST) annotation).value(), true);
  } else if (annotation instanceof PUT) {
    parseHttpMethodAndPath("PUT", ((PUT) annotation).value(), true);
  } else if (annotation instanceof OPTIONS) {
    parseHttpMethodAndPath("OPTIONS", ((OPTIONS) annotation).value(), false);
  } else if (annotation instanceof HTTP) {
    HTTP http = (HTTP) annotation;
    parseHttpMethodAndPath(http.method(), http.path(), http.hasBody());
  } else if (annotation instanceof retrofit2.http.Headers) {
    // Parse static headers...
  } else if (annotation instanceof Multipart) {
    // Set multipart encoding...
  } else if (annotation instanceof FormUrlEncoded) {
    // Set form encoding...
  }
}

Notice the hasBody parameter, this is determined by the HTTP method. GET, DELETE, HEAD, and OPTIONS don't have request bodies by default, while POST, PUT, and PATCH do. The @HTTP annotation allows customizing this.

The parseHttpMethodAndPath() method in the RequestFactory class also validates URL parameters:

private void parseHttpMethodAndPath(String httpMethod, String value, boolean hasBody) {
  if (this.httpMethod != null) {
    throw methodError(
        method,
        "Only one HTTP method is allowed. Found: %s and %s.",
        this.httpMethod,
        httpMethod);
  }
  this.httpMethod = httpMethod;
  this.hasBody = hasBody;

  if (value.isEmpty()) {
    return;
  }

  // Get the relative URL path and existing query string, if present.
  int question = value.indexOf('?');
  if (question != -1 && question < value.length() - 1) {
    // Ensure the query string does not have any named parameters.
    String queryParams = value.substring(question + 1);
    Matcher queryParamMatcher = PARAM_URL_REGEX.matcher(queryParams);
    if (queryParamMatcher.find()) {
      throw methodError(
          method,
          "URL query string \"%s\" must not have replace block. "
              + "For dynamic query parameters use @Query.",
          queryParams);
    }
  }

  this.relativeUrl = value;
  this.relativeUrlParamNames = parsePathParameters(value);
}

The validation ensures you can't put {paramName} placeholders in the query string, those must be added via @Query parameters. This catches a common mistake at startup time rather than at runtime.

Parameter annotation parsing: The handler pattern

Each method parameter gets a ParameterHandler that knows how to apply that parameter to the request. The parsing logic is in the RequestFactory class:

private ParameterHandler<?> parseParameterAnnotation(
    int p, Type type, Annotation[] annotations, Annotation annotation) {
  if (annotation instanceof Url) {
    // Handle @Url parameter
    validateResolvableType(p, type);
    if (gotUrl) {
      throw parameterError(method, p, "Multiple @Url method annotations found.");
    }
    // ... more validation
    gotUrl = true;
    return new ParameterHandler.RelativeUrl(method, p);

  } else if (annotation instanceof Path) {
    // Handle @Path parameter
    validateResolvableType(p, type);
    Path path = (Path) annotation;
    String name = path.value();
    validatePathName(p, name);

    Converter<?, String> converter = retrofit.stringConverter(type, annotations);
    return new ParameterHandler.Path<>(method, p, name, converter, path.encoded());

  } else if (annotation instanceof Query) {
    // Handle @Query parameter
    validateResolvableType(p, type);
    Query query = (Query) annotation;
    String name = query.value();
    boolean encoded = query.encoded();

    Class<?> rawParameterType = Utils.getRawType(type);
    if (Iterable.class.isAssignableFrom(rawParameterType)) {
      // Handle List<String> query parameters - creates repeating params
      ParameterizedType parameterizedType = (ParameterizedType) type;
      Type iterableType = Utils.getParameterUpperBound(0, parameterizedType);
      Converter<?, String> converter = retrofit.stringConverter(iterableType, annotations);
      return new ParameterHandler.Query<>(name, converter, encoded).iterable();
    } else if (rawParameterType.isArray()) {
      // Handle String[] query parameters - creates repeating params
      Class<?> arrayComponentType = boxIfPrimitive(rawParameterType.getComponentType());
      Converter<?, String> converter = retrofit.stringConverter(arrayComponentType, annotations);
      return new ParameterHandler.Query<>(name, converter, encoded).array();
    } else {
      // Handle single query parameter
      Converter<?, String> converter = retrofit.stringConverter(type, annotations);
      return new ParameterHandler.Query<>(name, converter, encoded);
    }
  } else if (annotation instanceof Body) {
    // Handle @Body parameter
    // ...
  }
  // ... more parameter types
  return null;
}

Notice the decorator pattern for collections. If you declare @Query("tag") List<String> tags, Retrofit wraps the basic Query handler with an .iterable() decorator. When invoked, this iterates over the list and applies the base handler to each element, generating repeating query parameters like ?tag=kotlin&tag=java&tag=android.

The ParameterHandler abstraction

All parameter handlers extend the abstract ParameterHandler<T> class ParameterHandler:

abstract class ParameterHandler<T> {
  abstract void apply(RequestBuilder builder, @Nullable T value) throws IOException;

  final ParameterHandler<Iterable<T>> iterable() {
    return new ParameterHandler<Iterable<T>>() {
      @Override
      void apply(RequestBuilder builder, @Nullable Iterable<T> values) throws IOException {
        if (values == null) return; // Skip null values.

        for (T value : values) {
          ParameterHandler.this.apply(builder, value);
        }
      }
    };
  }

  final ParameterHandler<Object> array() {
    return new ParameterHandler<Object>() {
      @Override
      void apply(RequestBuilder builder, @Nullable Object values) throws IOException {
        if (values == null) return; // Skip null values.

        for (int i = 0, size = Array.getLength(values); i < size; i++) {
          ParameterHandler.this.apply(builder, (T) Array.get(values, i));
        }
      }
    };
  }
}

This is the strategy pattern in action. Each annotation type has its own strategy for applying parameter values to the request builder. Let's look at a concrete implementation of the Path handler in the ParameterHandler class:

static final class Path<T> extends ParameterHandler<T> {
  private final Method method;
  private final int p;
  private final String name;
  private final Converter<T, String> valueConverter;
  private final boolean encoded;

  Path(Method method, int p, String name, Converter<T, String> valueConverter, boolean encoded) {
    this.method = method;
    this.p = p;
    this.name = Objects.requireNonNull(name, "name == null");
    this.valueConverter = valueConverter;
    this.encoded = encoded;
  }

  @Override
  void apply(RequestBuilder builder, @Nullable T value) throws IOException {
    if (value == null) {
      throw Utils.parameterError(
          method, p, "Path parameter \"" + name + "\" value must not be null.");
    }
    builder.addPathParam(name, valueConverter.convert(value), encoded);
  }
}

The handler holds references to the method and parameter index p for error reporting, the path parameter name (e.g., "user" from @Path("user")), and a Converter to transform the parameter value to a string. When apply() is called, it validates the value is non-null (path parameters can't be null) and delegates to the RequestBuilder.

Request construction: From parameters to OkHttp

Once annotations are parsed into a RequestFactory and parameter values are provided, Retrofit constructs an OkHttp Request. This happens in the RequestFactory.create() method:

okhttp3.Request create(@Nullable Object instance, Object[] args) throws IOException {
  @SuppressWarnings("unchecked")
  ParameterHandler<Object>[] handlers = (ParameterHandler<Object>[]) parameterHandlers;

  int argumentCount = args.length;
  if (argumentCount != handlers.length) {
    throw new IllegalArgumentException(
        "Argument count (" + argumentCount + ") doesn't match expected count ("
        + handlers.length + ")");
  }

  RequestBuilder requestBuilder =
      new RequestBuilder(
          httpMethod,
          baseUrl,
          relativeUrl,
          headers,
          contentType,
          hasBody,
          isFormEncoded,
          isMultipart);

  if (isKotlinSuspendFunction) {
    // The Continuation is the last parameter and the handlers array contains null at that index.
    argumentCount--;
  }

  List<Object> argumentList = new ArrayList<>(argumentCount);
  for (int p = 0; p < argumentCount; p++) {
    argumentList.add(args[p]);
    handlers[p].apply(requestBuilder, args[p]);
  }

  return requestBuilder
      .get()
      .tag(Invocation.class, new Invocation(service, instance, method, argumentList))
      .build();
}

This creates a RequestBuilder (Retrofit's wrapper around OkHttp's Request.Builder), applies all parameter handlers, and builds the final request. Notice the .tag(Invocation.class, new Invocation(...)) at the end, this attaches metadata about the Retrofit method call to the OkHttp request. This is useful for logging interceptors that want to know which API method generated a request.

The RequestBuilder class

The RequestBuilder class handles the mechanics of URL construction, header management, and body encoding:

final class RequestBuilder {
  private static final char[] HEX_DIGITS = {
    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'
  };
  private static final String PATH_SEGMENT_ALWAYS_ENCODE_SET = " \"<>^`{}|\\?#";

  /**
   * Matches strings that contain {@code .} or {@code ..} as a complete path segment.
   * This prevents path traversal attacks.
   */
  private static final Pattern PATH_TRAVERSAL = Pattern.compile("(.*/)?(\\.|%2e|%2E){1,2}(/.*)?");

  private final String method;
  private final HttpUrl baseUrl;
  private @Nullable String relativeUrl;
  private @Nullable HttpUrl.Builder urlBuilder;

  private final Request.Builder requestBuilder;
  private final Headers.Builder headersBuilder;
  private @Nullable MediaType contentType;

  private final boolean hasBody;
  private @Nullable MultipartBody.Builder multipartBuilder;
  private @Nullable FormBody.Builder formBuilder;
  private @Nullable RequestBody body;

  // ... constructor and methods
}

The key security feature here is the PATH_TRAVERSAL pattern. When adding path parameters, Retrofit checks for .. or . segments (including their percent-encoded forms %2E) and throws an exception. This prevents attacks where a malicious user could pass ../../admin/delete as a path parameter.

Path parameter substitution

The addPathParam() method in the RequestBuilder class shows how path parameters are substituted:

void addPathParam(String name, String value, boolean encoded) {
  if (relativeUrl == null) {
    // The relative URL is cleared when the first query parameter is set.
    throw new AssertionError();
  }
  String replacement = canonicalizeForPath(value, encoded);
  String newRelativeUrl = relativeUrl.replace("{" + name + "}", replacement);
  if (PATH_TRAVERSAL.matcher(newRelativeUrl).matches()) {
    throw new IllegalArgumentException(
        "@Path parameters shouldn't perform path traversal ('.' or '..'): " + value);
  }
  relativeUrl = newRelativeUrl;
}

The canonicalizeForPath() method percent-encodes special characters according to RFC 3986. For example, a space becomes %20, and a # becomes %23. The encoded flag allows you to bypass this encoding if you've already percent-encoded the value yourself (useful for dynamic path segments that contain slashes).

Form encoding and multipart

For @FormUrlEncoded requests, Retrofit uses OkHttp's FormBody.Builder:

if (isFormEncoded) {
  formBuilder = new FormBody.Builder();
}

// Later, when @Field parameters are applied:
void addFormField(String name, String value, boolean encoded) {
  if (encoded) {
    formBuilder.addEncoded(name, value);
  } else {
    formBuilder.add(name, value);
  }
}

The form builder creates application/x-www-form-urlencoded bodies like username=john&password=secret.

For @Multipart requests, Retrofit uses MultipartBody.Builder:

if (isMultipart) {
  multipartBuilder = new MultipartBody.Builder();
  multipartBuilder.setType(MultipartBody.FORM);
}

// Later, when @Part parameters are applied:
void addPart(Headers headers, RequestBody body) {
  multipartBuilder.addPart(headers, body);
}

This creates multipart/form-data bodies with MIME boundaries, which is essential for file uploads.

Call execution: Synchronous, asynchronous, and coroutine paths

Once a request is constructed, it needs to be executed. Retrofit provides three execution modes through the Call<T> interface, implemented by OkHttpCall<T> in the OkHttpCall class.

The OkHttpCall implementation

final class OkHttpCall<T> implements Call<T> {
  private final RequestFactory requestFactory;
  private final Object instance;  // The service interface proxy instance
  private final Object[] args;    // Method call arguments
  private final okhttp3.Call.Factory callFactory;  // Typically OkHttpClient
  private final Converter<ResponseBody, T> responseConverter;

  private volatile boolean canceled;

  @GuardedBy("this")
  private @Nullable okhttp3.Call rawCall;

  @GuardedBy("this")
  private @Nullable Throwable creationFailure;

  @GuardedBy("this")
  private boolean executed;

  // ... constructor
}

Notice the thread-safety annotations and the volatile boolean canceled. This class is designed for safe concurrent access, you can call cancel() from one thread while another thread is executing the request.

Synchronous execution

The execute() method in the OkHttpCall class performs a blocking HTTP request:

@Override
public Response<T> execute() throws IOException {
  okhttp3.Call call;

  synchronized (this) {
    if (executed) throw new IllegalStateException("Already executed.");
    executed = true;

    call = getRawCall();
  }

  if (canceled) {
    call.cancel();
  }

  return parseResponse(call.execute());
}

The synchronization ensures that execute() can only be called once per OkHttpCall instance. The getRawCall() method lazily creates the OkHttp call:

@GuardedBy("this")
private okhttp3.Call getRawCall() throws IOException {
  okhttp3.Call call = rawCall;
  if (call != null) return call;

  // Re-throw previous failures if this isn't the first attempt.
  if (creationFailure != null) {
    if (creationFailure instanceof IOException) {
      throw (IOException) creationFailure;
    } else if (creationFailure instanceof RuntimeException) {
      throw (RuntimeException) creationFailure;
    } else {
      throw (Error) creationFailure;
    }
  }

  // Create and remember either the success or the failure.
  try {
    return rawCall = createRawCall();
  } catch (RuntimeException | Error | IOException e) {
    throwIfFatal(e); // Do not assign a fatal error to creationFailure.
    creationFailure = e;
    throw e;
  }
}

This implements memoization of both success and failure. If requestFactory.create() throws an exception (e.g., due to parameter conversion failure), that exception is saved. Future calls to getRawCall() re-throw the same exception without retrying the request creation. This is important for error reporting, you get the original exception, not a "Already executed" error.

Asynchronous execution

The enqueue() method in the OkHttpCall class performs a non-blocking HTTP request:

@Override
public void enqueue(final Callback<T> callback) {
  Objects.requireNonNull(callback, "callback == null");

  okhttp3.Call call;
  Throwable failure;

  synchronized (this) {
    if (executed) throw new IllegalStateException("Already executed.");
    executed = true;

    call = rawCall;
    failure = creationFailure;
    if (call == null && failure == null) {
      try {
        call = rawCall = createRawCall();
      } catch (Throwable t) {
        throwIfFatal(t);
        failure = creationFailure = t;
      }
    }
  }

  if (failure != null) {
    callback.onFailure(this, failure);
    return;
  }

  if (canceled) {
    call.cancel();
  }

  call.enqueue(
      new okhttp3.Callback() {
        @Override
        public void onResponse(okhttp3.Call call, okhttp3.Response rawResponse) {
          Response<T> response;
          try {
            response = parseResponse(rawResponse);
          } catch (Throwable e) {
            throwIfFatal(e);
            callFailure(e);
            return;
          }

          try {
            callback.onResponse(OkHttpCall.this, response);
          } catch (Throwable t) {
            throwIfFatal(t);
            t.printStackTrace(); // TODO this is not great
          }
        }

        @Override
        public void onFailure(okhttp3.Call call, IOException e) {
          callFailure(e);
        }

        private void callFailure(Throwable e) {
          try {
            callback.onFailure(OkHttpCall.this, e);
          } catch (Throwable t) {
            throwIfFatal(t);
            t.printStackTrace();
          }
        }
      });
}

The critical difference from execute() is that request creation failures are reported via the callback rather than thrown. The callback is executed on OkHttp's dispatcher thread pool by default, but Retrofit allows you to customize this with a callback executor (on Android, this is typically the main thread).

Response parsing

The parseResponse() method in the OkHttpCall class handles the conversion from OkHttp's raw response to Retrofit's typed response:

Response<T> parseResponse(okhttp3.Response rawResponse) throws IOException {
  ResponseBody rawBody = rawResponse.body();

  // Remove the body's source (the only stateful object) so we can pass the response along.
  rawResponse =
      rawResponse
          .newBuilder()
          .body(new NoContentResponseBody(rawBody.contentType(), rawBody.contentLength()))
          .build();

  int code = rawResponse.code();
  if (code < 200 || code >= 300) {
    try {
      // Buffer the entire body to avoid future I/O.
      ResponseBody bufferedBody = Utils.buffer(rawBody);
      return Response.error(bufferedBody, rawResponse);
    } finally {
      rawBody.close();
    }
  }

  if (code == 204 || code == 205) {
    rawBody.close();
    return Response.success(null, rawResponse);
  }

  ExceptionCatchingResponseBody catchingBody = new ExceptionCatchingResponseBody(rawBody);
  try {
    T body = responseConverter.convert(catchingBody);
    return Response.success(body, rawResponse);
  } catch (RuntimeException e) {
    // If the underlying source threw an exception, propagate that rather than indicating
    // it was a runtime exception.
    catchingBody.throwIfCaught();
    throw e;
  }
}

There are several subtle details here:

  1. Response body replacement: The original ResponseBody is replaced with a NoContentResponseBody that throws if you try to read it. This prevents accidental double-reading of the response stream after it's been consumed by the converter.

  2. Error body buffering: For error responses (non-2xx status codes), the entire body is buffered into memory. This is necessary because the error body might be read multiple times (e.g., once for logging, once for error handling), and OkHttp response bodies are single-use streams.

  3. 204/205 handling: HTTP 204 No Content and 205 Reset Content responses don't have bodies. Retrofit automatically returns null for these status codes, avoiding converter exceptions.

  4. Exception catching wrapper: The ExceptionCatchingResponseBody wraps the response body stream and captures any IOException thrown during reading. This allows Retrofit to distinguish between converter exceptions (e.g., malformed JSON) and network exceptions (e.g., connection reset during reading).

The ExceptionCatchingResponseBody

In the OkHttpCall class:

static final class ExceptionCatchingResponseBody extends ResponseBody {
  private final ResponseBody delegate;
  private final BufferedSource delegateSource;
  @Nullable IOException thrownException;

  ExceptionCatchingResponseBody(ResponseBody delegate) {
    this.delegate = delegate;
    this.delegateSource =
        Okio.buffer(
            new ForwardingSource(delegate.source()) {
              @Override
              public long read(Buffer sink, long byteCount) throws IOException {
                try {
                  return super.read(sink, byteCount);
                } catch (IOException e) {
                  thrownException = e;
                  throw e;
                }
              }
            });
  }

  // ... delegate methods

  void throwIfCaught() throws IOException {
    if (thrownException != null) {
      throw thrownException;
    }
  }
}

This is the decorator pattern in action. The wrapper delegates all calls to the original response body but intercepts IOException during reading. When the converter throws a RuntimeException, Retrofit calls throwIfCaught() to check if the exception was caused by an I/O error rather than a conversion error.

The converter factory chain: Serialization and deserialization

Retrofit's converter system uses the chain of responsibility pattern to find the right converter for each type. Converters are registered in the Retrofit.Builder and tried in order until one handles the type.

Built-in converters

Retrofit includes converters for basic types in the BuiltInConverters class:

final class BuiltInConverters extends Converter.Factory {
  @Override
  public @Nullable Converter<ResponseBody, ?> responseBodyConverter(
      Type type, Annotation[] annotations, Retrofit retrofit) {
    if (type == ResponseBody.class) {
      // Streaming or buffered ResponseBody
      return Utils.isAnnotationPresent(annotations, Streaming.class)
          ? StreamingResponseBodyConverter.INSTANCE
          : BufferResponseBodyConverter.INSTANCE;
    }
    if (type == Void.class) {
      return VoidResponseBodyConverter.INSTANCE;
    }
    if (Utils.isUnit(type)) {
      return UnitResponseBodyConverter.INSTANCE;
    }
    return null;
  }

  @Override
  public @Nullable Converter<?, RequestBody> requestBodyConverter(
      Type type,
      Annotation[] parameterAnnotations,
      Annotation[] methodAnnotations,
      Retrofit retrofit) {
    if (RequestBody.class.isAssignableFrom(Utils.getRawType(type))) {
      return RequestBodyConverter.INSTANCE;
    }
    return null;
  }

  @Override
  public @Nullable Converter<?, String> stringConverter(
      Type type, Annotation[] annotations, Retrofit retrofit) {
    return null; // Fallback to toString() in Retrofit
  }
}

Notice how each converter factory returns null if it can't handle the type. This signals Retrofit to try the next factory in the chain.

Converter lookup logic

The converter lookup is implemented in the Retrofit class:

public <T> Converter<ResponseBody, T> responseBodyConverter(Type type, Annotation[] annotations) {
  return nextResponseBodyConverter(null, type, annotations);
}

public <T> Converter<ResponseBody, T> nextResponseBodyConverter(
    @Nullable Converter.Factory skipPast, Type type, Annotation[] annotations) {
  Objects.requireNonNull(type, "type == null");
  Objects.requireNonNull(annotations, "annotations == null");

  int start = converterFactories.indexOf(skipPast) + 1;
  for (int i = start, count = converterFactories.size(); i < count; i++) {
    Converter<ResponseBody, ?> converter =
        converterFactories.get(i).responseBodyConverter(type, annotations, this);
    if (converter != null) {
      //noinspection unchecked
      return (Converter<ResponseBody, T>) converter;
    }
  }

  StringBuilder builder =
      new StringBuilder("Could not locate ResponseBody converter for ")
          .append(type)
          .append(".\n");
  // ... error message construction
  throw new IllegalArgumentException(builder.toString());
}

The skipPast parameter enables delegating converters. For example, a converter that adds encryption could delegate to the next converter in the chain:

@Override
public Converter<ResponseBody, ?> responseBodyConverter(...) {
  Converter<ResponseBody, ?> delegate = retrofit.nextResponseBodyConverter(this, type, annotations);
  return (ResponseBody body) -> decrypt(delegate.convert(body));
}

This pattern allows composing converters without tight coupling.

String converters for parameters

For path parameters, query parameters, and headers, Retrofit needs to convert values to strings. The stringConverter() method in the Retrofit class:

public <T> Converter<T, String> stringConverter(Type type, Annotation[] annotations) {
  Objects.requireNonNull(type, "type == null");
  Objects.requireNonNull(annotations, "annotations == null");

  for (int i = 0, count = converterFactories.size(); i < count; i++) {
    Converter<?, String> converter =
        converterFactories.get(i).stringConverter(type, annotations, this);
    if (converter != null) {
      //noinspection unchecked
      return (Converter<T, String>) converter;
    }
  }

  // Nothing matched. Resort to default converter which just calls toString().
  //noinspection unchecked
  return (Converter<T, String>) BuiltInConverters.ToStringConverter.INSTANCE;
}

Notice the fallback, if no converter factory handles the type, Retrofit falls back to calling toString(). This means you can use any type for path/query parameters as long as it has a sensible toString() implementation.

The call adapter factory chain: Return type transformation

Retrofit's real power comes from the ability to return different types from service methods. The CallAdapter interface enables this flexibility.

The CallAdapter contract

public interface CallAdapter<R, T> {
  /**
   * Returns the value type that this adapter uses when converting the HTTP response body to a
   * Java object. For example, the response type for {@code Call<Repo>} is {@code Repo}.
   */
  Type responseType();

  /**
   * Returns an instance of {@code T} which delegates to {@code call}.
   */
  T adapt(Call<R> call);
}

The two type parameters are:

  • R: The response type that the adapter expects (e.g., Repo for Call<Repo>)
  • T: The adapted type that the service method returns (e.g., Observable<Repo>)

Built-in call adapters

Retrofit provides a default call adapter that returns Call<T> unchanged in the DefaultCallAdapterFactory class:

final class DefaultCallAdapterFactory extends CallAdapter.Factory {
  private final @Nullable Executor callbackExecutor;

  DefaultCallAdapterFactory(@Nullable Executor callbackExecutor) {
    this.callbackExecutor = callbackExecutor;
  }

  @Override
  public @Nullable CallAdapter<?, ?> get(
      Type returnType, Annotation[] annotations, Retrofit retrofit) {
    if (getRawType(returnType) != Call.class) {
      return null;
    }
    if (!(returnType instanceof ParameterizedType)) {
      throw new IllegalArgumentException(
          "Call return type must be parameterized as Call<Foo> or Call<? extends Foo>");
    }

    final Type responseType = getParameterUpperBound(0, (ParameterizedType) returnType);

    final Executor executor =
        Utils.isAnnotationPresent(annotations, SkipCallbackExecutor.class)
            ? null
            : callbackExecutor;

    return new CallAdapter<Object, Call<?>>() {
      @Override
      public Type responseType() {
        return responseType;
      }

      @Override
      public Call<Object> adapt(Call<Object> call) {
        return executor == null ? call : new ExecutorCallbackCall<>(executor, call);
      }
    };
  }
}

The ExecutorCallbackCall wrapper ensures callbacks are executed on the specified executor (typically the Android main thread). This is how Retrofit makes it safe to update UI from async API call callbacks.

Kotlin coroutine support

Retrofit has first-class support for Kotlin suspend functions. The detection happens in the HttpServiceMethod.parseAnnotations() method:

static <ResponseT, ReturnT> HttpServiceMethod<ResponseT, ReturnT> parseAnnotations(
    Retrofit retrofit, Method method, RequestFactory requestFactory) {
  boolean isKotlinSuspendFunction = requestFactory.isKotlinSuspendFunction;
  boolean continuationWantsResponse = false;
  boolean continuationIsUnit = false;

  Annotation[] annotations = method.getAnnotations();
  Type adapterType;
  if (isKotlinSuspendFunction) {
    Type[] parameterTypes = method.getGenericParameterTypes();
    Type responseType =
        Utils.getParameterLowerBound(
            0, (ParameterizedType) parameterTypes[parameterTypes.length - 1]);
    if (getRawType(responseType) == Response.class && responseType instanceof ParameterizedType) {
      // suspend fun getUser(): Response<User>
      responseType = Utils.getParameterUpperBound(0, (ParameterizedType) responseType);
      continuationWantsResponse = true;
    } else {
      continuationIsUnit = Utils.isUnit(responseType);
    }

    adapterType = new Utils.ParameterizedTypeImpl(null, Call.class, responseType);
    annotations = SkipCallbackExecutorImpl.ensurePresent(annotations);
  } else {
    adapterType = method.getGenericReturnType();
  }

  // ... create call adapter and converter
}

Kotlin suspend functions are compiled to regular Java methods with an extra Continuation parameter. Retrofit detects this and returns one of three HttpServiceMethod implementations:

  1. SuspendForResponse: For suspend fun getUser(): Response<User>, returns the full Response wrapper
  2. SuspendForBody: For suspend fun getUser(): User, returns just the body
  3. CallAdapted: For non-suspend methods, returns Call<User> or adapted type

The actual suspension happens in the KotlinExtensions using Kotlin's suspendCancellableCoroutine:

suspend fun <T> Call<T>.await(): T {
  return suspendCancellableCoroutine { continuation ->
    continuation.invokeOnCancellation {
      cancel()
    }
    enqueue(object : Callback<T> {
      override fun onResponse(call: Call<T>, response: Response<T>) {
        if (response.isSuccessful) {
          val body = response.body()
          if (body == null) {
            val invocation = call.request().tag(Invocation::class.java)!!
            val method = invocation.method()
            val e = KotlinNullPointerException(
                "Response from " +
                    method.declaringClass.name +
                    "." +
                    method.name +
                    " was null but response body type was declared as non-null")
            continuation.resumeWithException(e)
          } else {
            continuation.resume(body)
          }
        } else {
          continuation.resumeWithException(HttpException(response))
        }
      }

      override fun onFailure(call: Call<T>, t: Throwable) {
        continuation.resumeWithException(t)
      }
    })
  }
}

This converts the callback-based Call.enqueue() API into a suspending function. The invokeOnCancellation hook ensures that canceling the coroutine also cancels the HTTP request.

Performance characteristics and design trade-offs

Retrofit makes several performance trade-offs that are worth understanding if you're optimizing network code or debugging performance issues.

Annotation parsing cost

Annotation parsing happens lazily on first method invocation. For a typical service method, parsing takes ~1-5ms on modern devices. This cost is amortized because:

  1. Parsing is cached per method in serviceMethodCache
  2. Subsequent calls to the same method are lock-free lookups (~nanoseconds)
  3. Most apps call the same endpoints repeatedly, so caching is effective

For apps that call hundreds of different endpoints once each (rare), you can enable eager validation:

Retrofit retrofit = new Retrofit.Builder()
    .validateEagerly(true)  // Parse all methods at create() time
    .build();

This moves parsing cost to startup, which can be helpful for catching annotation errors early.

Dynamic proxy overhead

JDK dynamic proxies add a small overhead to every method call, typically 10-50 nanoseconds for the proxy dispatch. This is negligible compared to network latency (typically 50-500 milliseconds), but it's not free.

The trade-off is worth it for the flexibility and compile-time type safety. Alternatives like annotation processors would require recompiling every time you change an API definition.

Thread safety through immutability

Most Retrofit classes are immutable after construction:

  • Retrofit: Immutable after Builder.build()
  • RequestFactory: Immutable after parsing
  • ServiceMethod: Immutable after parsing
  • ParameterHandler instances: Immutable after creation

The only mutable state is in OkHttpCall:

private volatile boolean canceled;
@GuardedBy("this") private @Nullable okhttp3.Call rawCall;
@GuardedBy("this") private @Nullable Throwable creationFailure;
@GuardedBy("this") private boolean executed;

Using @GuardedBy annotations and volatile for visibility ensures thread-safe access without expensive synchronization on the hot path.

Converter and adapter factory lookup

Both converter and adapter factories are looked up using linear search through the factory lists. For typical apps with 2-5 factories, this is fine. For apps with dozens of factories, consider ordering them by frequency of use (most common first).

The lookup happens once per method during annotation parsing, so it's amortized by caching.

Conclusion

In this article, you've explored the internal mechanisms of Retrofit, examining how the library transforms interface methods into HTTP requests through a sophisticated combination of dynamic proxies, lazy annotation parsing, and pluggable factories. From the Proxy.newProxyInstance() call that creates implementation classes at runtime, to the three-state serviceMethodCache that enables lock-free cached lookups, to the ParameterHandler strategy pattern that applies method parameters to requests,Retrofit showcases careful attention to both developer experience and runtime performance.

The internal implementations reveal several key design principles: lazy initialization with aggressive caching for fast repeated calls, immutability for thread safety without synchronization overhead, factory chains for pluggable serialization and adaptation, and decorator patterns for composable functionality. The annotation parsing validates errors at first call rather than relying on reflection at runtime, while the dynamic proxy mechanism enables compile-time type safety without code generation.