Retrofit and OpenFeign
Declarative HTTP client patterns for Java enterprise services, including proxy lifecycle, encoder/decoder behavior, error mapping, generated clients, observability, and production trade-offs
Part 081 — Retrofit and OpenFeign
Fokus part ini: memahami declarative HTTP client sebagai integration boundary, bukan sekadar interface Java yang diberi annotation.
Di part sebelumnya, outbound HTTP dilihat dari level rendah: client lifecycle, connection pool, timeout, response closing, entity handling, dan Jersey Client.
Part ini naik satu level:
Business code
-> Java interface
-> generated/proxy HTTP client
-> encoder/decoder
-> resilience/auth/logging layer
-> downstream service
Retrofit dan OpenFeign sama-sama membuat outbound HTTP terasa seperti pemanggilan method Java.
Itu nyaman.
Tetapi juga berbahaya kalau abstraction ini menyembunyikan:
- network failure
- latency
- retry behavior
- body buffering
- error mapping
- serialization compatibility
- timeout hierarchy
- connection pool behavior
- observability propagation
- authentication/signing
- generated code drift
- downstream contract drift
Senior engineer harus bisa melihat melewati interface.
1. Core Mental Model
Declarative HTTP client mengubah interface Java menjadi request HTTP.
Contoh mental model:
interface PricingClient {
PriceResponse quotePrice(PriceRequest request);
}
Runtime sebenarnya:
PricingClient proxy
-> method metadata
-> HTTP method/path template
-> parameter expansion
-> request body encoder
-> header injection
-> HTTP transport
-> response status handling
-> response body decoder
-> exception/error mapping
Jangan tertipu oleh bentuk method call.
Ini tetap remote call.
Local method call semantics != remote HTTP call semantics
Remote call memiliki:
- partial failure
- timeout
- duplicate delivery risk
- authentication expiry
- unknown result after timeout
- compatibility issue
- downstream overload
- network partition
2. Why Declarative Clients Exist
Declarative clients dibuat untuk mengurangi boilerplate:
client.target(baseUrl)
.path("/prices")
.request(APPLICATION_JSON_TYPE)
.post(Entity.json(request));
menjadi:
pricingClient.createPrice(request);
Benefit:
- interface lebih mudah dibaca
- API contract terlihat di satu tempat
- test stubbing lebih mudah
- request building lebih konsisten
- generated client bisa disinkronkan dengan OpenAPI
- integration adapter lebih tipis
Trade-off:
- failure mode bisa tersembunyi
- annotation bisa menjadi mini-framework
- exception taxonomy bisa tidak jelas
- timeout/retry bisa tersebar
- behavior generated code kadang sulit direview
- debugging membutuhkan pengetahuan framework
Prinsipnya:
Declarative client is acceptable only if the hidden runtime behavior is standardized and observable.
3. Retrofit Mental Model
Retrofit populer karena interface-based dan kuat dalam mapping request/response.
Typical shape:
public interface ProductCatalogApi {
@POST("/catalog/products/search")
Call<ProductSearchResponse> search(@Body ProductSearchRequest request);
}
Atau dengan return type lain tergantung adapter:
CompletableFuture<ProductSearchResponse>
Retrofit biasanya terdiri dari:
Retrofit instance
-> baseUrl
-> converter factory
-> call adapter factory
-> underlying HTTP client
-> service interface proxy
Hal penting:
baseUrlharus stable dan environment-specific- converter menentukan JSON/XML behavior
- call adapter menentukan sync/async/reactive behavior
- underlying client menentukan pool, timeout, TLS, proxy, DNS
- annotation menentukan method/path/query/header/body
Retrofit bukan transport murni.
Transport biasanya disediakan oleh client bawahnya, misalnya OkHttp.
4. OpenFeign Mental Model
OpenFeign juga interface-based.
Typical shape:
public interface OrderClient {
@RequestLine("GET /orders/{id}")
OrderResponse getOrder(@Param("id") String id);
}
Atau dengan contract style tertentu:
interface OrderClient {
@GetMapping("/orders/{id}")
OrderResponse getOrder(@PathVariable("id") String id);
}
Tergantung integrasi dan contract yang dipakai.
OpenFeign biasanya terdiri dari:
Feign builder
-> contract parser
-> encoder
-> decoder
-> error decoder
-> request interceptor
-> retryer
-> logger
-> client transport
-> dynamic proxy
Key idea:
Feign is a proxy factory plus pluggable HTTP semantics.
Yang harus direview bukan hanya interface, tetapi builder/config yang membentuk proxy.
5. Interface Is a Contract Boundary
Declarative interface harus dianggap sebagai boundary kontrak.
Bad:
interface DownstreamClient {
Object call(Object request);
}
Better:
interface PricingApiClient {
PriceCalculationResponse calculatePrice(PriceCalculationRequest request);
}
Even better in application architecture:
Application Service
-> Domain Port
-> Integration Adapter
-> Retrofit/Feign Client Interface
Example:
public interface PricingPort {
PriceResult calculate(PriceCommand command);
}
final class HttpPricingAdapter implements PricingPort {
private final PricingHttpClient client;
@Override
public PriceResult calculate(PriceCommand command) {
PriceRequest request = mapper.toRequest(command);
PriceResponse response = client.calculate(request);
return mapper.toDomain(response);
}
}
The domain/application layer should not depend directly on Retrofit/Feign annotations.
Why:
- easier to test
- easier to replace client implementation
- keeps HTTP exception mapping out of business logic
- prevents transport DTO from leaking into domain
- centralizes observability and resilience
6. Annotation Mapping Risks
Declarative client annotation looks simple.
But small mistakes become production bugs.
Examples:
Wrong path variable name
-> runtime failure or wrong URL
Wrong query encoding
-> downstream filtering bug
Wrong content type
-> 415 Unsupported Media Type
Wrong accept header
-> 406 Not Acceptable or wrong representation
Wrong body annotation
-> empty request body
Wrong collection expansion
-> query compatibility bug
Senior review should check annotation mapping as carefully as endpoint implementation.
Checklist:
- Does HTTP method match operation semantics?
- Is path versioned consistently?
- Are query params encoded correctly?
- Are optional params omitted or sent as empty string?
- Are repeated query params supported by downstream?
- Are headers explicit?
- Is content type explicit?
- Is response type correct?
- Are 204/empty body responses handled?
- Are redirects allowed or forbidden?
7. Encoder and Decoder Behavior
Encoder converts Java request into HTTP body.
Decoder converts HTTP body into Java response.
Request DTO -> encoder -> bytes on wire
bytes on wire -> decoder -> Response DTO
Failure modes:
- unknown JSON field rejected unexpectedly
- enum value not recognized
- date format mismatch
- BigDecimal precision loss
- null handling mismatch
- polymorphic type security issue
- large body fully buffered
- empty response body decoded as error
- downstream error body decoded as success DTO
For enterprise services, encoder/decoder must be centrally configured.
Bad:
Each client creates its own ObjectMapper.
Better:
Shared ObjectMapper policy
-> date/time format
-> enum handling
-> null handling
-> unknown field policy
-> BigDecimal precision
-> security settings
Internal verification checklist:
- Is Jackson, JSON-B, or another mapper used?
- Is the mapper shared with server-side JAX-RS?
- Are date/time and BigDecimal policies consistent?
- Are unknown fields ignored or rejected?
- Are generated DTOs used?
- Are error DTOs decoded separately?
8. Error Decoder and Exception Taxonomy
HTTP clients must not leak raw transport exceptions into business logic.
Bad:
try {
return client.calculate(request);
} catch (Exception e) {
throw new RuntimeException(e);
}
Better:
HTTP 400 -> downstream validation/contract error
HTTP 401/403 -> auth/config/security error
HTTP 404 -> not found or stale reference, depending on domain
HTTP 409 -> conflict/concurrency/business state issue
HTTP 429 -> throttling/rate limit
HTTP 500 -> downstream technical error
HTTP 503 -> downstream unavailable
Timeout -> unknown outcome
Connection refused -> dependency unavailable
TLS failure -> platform/security/config failure
Declarative clients need an explicit error decoder.
Error decoder should produce application-level exception categories:
DownstreamBadRequestException
DownstreamUnauthorizedException
DownstreamForbiddenException
DownstreamNotFoundException
DownstreamConflictException
DownstreamRateLimitedException
DownstreamUnavailableException
DownstreamTimeoutException
DownstreamProtocolException
Do not map everything to one exception.
Why:
- retry decision depends on category
- alert severity depends on category
- client response mapping depends on category
- RCA depends on category
- fallback policy depends on category
9. Timeout Must Be Visible at Client Boundary
Declarative clients often hide timeout config.
That is dangerous.
Every outbound client needs explicit timeout policy:
connect timeout
read timeout
write timeout
call timeout / total deadline
pool acquisition timeout
DNS timeout if configurable
TLS handshake timeout if configurable
Timeout should align with inbound deadline.
Example:
Inbound API budget: 2000 ms
auth/filter/logging: 100 ms
business logic: 200 ms
downstream A: 500 ms
downstream B: 700 ms
buffer/error handling: 200 ms
Do not let downstream call timeout exceed inbound request timeout.
Bad:
API gateway timeout: 30s
JAX-RS service timeout: none
HTTP client read timeout: 60s
Kafka publish timeout: 120s
This creates zombie work after caller is gone.
Internal verification checklist:
- Where are timeouts configured?
- Are they per-client or global?
- Are defaults safe?
- Are timeouts lower than upstream/gateway timeout?
- Are timeout exceptions categorized separately?
- Are timeout metrics emitted per downstream?
10. Retry Must Not Be Hidden in the Client Library
Some HTTP client stacks include retry behavior.
Hidden retry is dangerous because it can multiply load.
Application retry
x HTTP client retry
x service mesh retry
x gateway retry
x SDK retry
= retry amplification
For declarative clients, retry must be explicitly configured and documented.
Retry is usually safer for:
- transient connection failure before request is sent
- 503 with
Retry-After - 429 with backoff
- idempotent GET/PUT/DELETE where semantics are understood
- POST only with idempotency key or explicit duplicate safety
Retry is risky for:
- non-idempotent POST
- payment/order submission
- quote finalization
- downstream operations with unknown outcome after timeout
- large file upload
- side-effecting workflow transition
Senior rule:
Retry policy belongs to the integration adapter, not hidden inside generated proxy behavior.
11. Request Interceptors
Declarative clients often support request interceptors.
Common interceptor responsibilities:
- add authorization token
- add service identity header
- add correlation ID
- add trace context
- add tenant ID
- add idempotency key
- add user-agent/client version
- add request signature
- add feature flag context if allowed
Risks:
- duplicate header injection
- stale token
- missing tenant context
- correlation ID generated too late
- secret logged accidentally
- request signing done before body finalized
- interceptor order wrong
Interceptors must be deterministic and testable.
Bad:
Every client manually adds headers.
Better:
Standard interceptor stack
-> identity
-> tenant
-> tracing
-> correlation
-> idempotency/signing
-> logging/redaction
Internal verification checklist:
- Where are interceptors registered?
- Are they global or per-client?
- Are tenant/security headers mandatory?
- Are sensitive headers redacted?
- Is trace context propagated?
- Is interceptor order documented?
12. Generated Client vs Handwritten Client
Generated clients are useful when contract is stable and generation is governed.
Benefits:
- reduces manual mapping errors
- keeps DTOs aligned with OpenAPI/protobuf
- faster onboarding
- consistent serialization shape
- easier compatibility diff
Risks:
- generated code is noisy
- business logic may depend on generated DTOs directly
- generator version drift changes behavior
- generated clients may hide retry/timeout defaults
- generated models may be awkward for domain logic
- regeneration can create huge PR diffs
Recommended boundary:
Generated client/DTOs stay inside integration adapter.
Domain/application layer uses internal model.
Do not let generated DTOs become your domain model.
Example:
GeneratedPriceResponse
-> adapter mapper
-> PriceResult
Internal verification checklist:
- Are clients generated from OpenAPI?
- Which generator and version?
- Is generation deterministic?
- Are generated sources committed or built?
- Are generated DTOs leaking into domain layer?
- Is compatibility checked before regeneration?
13. Sync vs Async Client Calls
Declarative clients can be synchronous or asynchronous.
Synchronous:
thread waits for downstream response
Pros:
- simpler call flow
- easier transaction reasoning
- easier exception handling
Cons:
- blocks request thread
- vulnerable to thread exhaustion
- needs tight timeout
Asynchronous:
call returns future/reactive type
Pros:
- can reduce blocking
- can compose concurrent downstream calls
- better for fan-out if controlled
Cons:
- context propagation risk
- cancellation risk
- harder error handling
- concurrency amplification
- more difficult debugging
Senior warning:
Async does not remove the need for timeout, bulkhead, and backpressure.
14. Fan-Out and Aggregation Risk
Declarative clients make remote calls look cheap.
This often causes accidental fan-out:
orders.stream()
.map(order -> pricingClient.calculate(order))
.toList();
If orders has 200 items, this may create 200 downstream calls.
Failure modes:
- downstream overload
- thread pool exhaustion
- connection pool exhaustion
- rate limit violation
- noisy neighbor effect
- latency explosion
- partial result ambiguity
Better:
- batch API if available
- bounded concurrency
- cache stable reference data
- prefetch with limit
- async with bulkhead
- reject overly expensive request
- expose query cost limit
Review question:
Can this code path multiply one inbound request into many outbound requests?
15. Observability for Declarative Clients
Every outbound client should emit signals by downstream dependency.
Minimum metrics:
http.client.requests.count{client,method,status_class}
http.client.duration{client,method,status_class}
http.client.errors.count{client,error_type}
http.client.timeouts.count{client}
http.client.retries.count{client}
http.client.circuit_breaker.state{client}
Tracing:
span name: HTTP GET /orders/{id}
attributes:
http.method
http.route or sanitized path template
http.status_code
peer.service / server.address
retry.count if applicable
Avoid high-cardinality labels:
- raw URL with IDs
- customer ID
- tenant ID unless explicitly approved
- quote ID
- order ID
- full error message
Logs:
- log one structured event for failed dependency call
- include correlation ID and downstream client name
- do not log token, secret, full PII payload
- sample noisy failures if needed
16. Retrofit vs OpenFeign: Practical Comparison
| Dimension | Retrofit | OpenFeign |
|---|---|---|
| Mental model | Interface + Retrofit builder | Interface + Feign builder |
| Transport | Commonly OkHttp-backed | Pluggable client transport |
| Encoder/decoder | Converter factories | Encoder/decoder components |
| Error handling | Call/Response handling or adapter-specific | ErrorDecoder is central concept |
| Contract style | Retrofit annotations | Feign native or other contracts |
| Generated client | Often via OpenAPI generator variants | Often via OpenAPI/Spring Cloud ecosystem variants |
| Good fit | Explicit adapter-owned clients | Declarative service-to-service clients |
| Main risk | Hidden body/converter/call adapter behavior | Hidden retry/error decoder/client config behavior |
The right question is not:
Which one is better?
The better question:
Which one matches the team's contract governance, resilience library, observability standard, and code generation policy?
17. Placement in JAX-RS Service Architecture
Recommended placement:
resource package
-> inbound HTTP/JAX-RS only
application/usecase package
-> orchestration and business use case
domain package
-> domain model/invariants
integration/client package
-> Retrofit/Feign interface
-> adapter
-> mapper
-> error decoder
-> resilience config
Avoid:
Resource method directly calls Feign/Retrofit client.
Why:
- endpoint becomes coupled to downstream contract
- difficult to test error mapping
- retry/fallback policy scattered
- domain logic becomes transport-aware
18. Failure Modes
Common failure modes:
| Failure | Typical Cause | Detection | Mitigation |
|---|---|---|---|
| Wrong URL | bad base URL/config | 404/connection failure | config validation at startup |
| Missing timeout | default infinite/large timeout | stuck threads | mandatory timeout policy |
| Connection leak | response body not consumed/closed | pool exhaustion | adapter pattern, tests |
| Wrong decoder | mapper mismatch | 500/deserialization error | shared mapper policy |
| Error body ignored | decoder only handles success DTO | misleading exceptions | error decoder |
| Hidden retry | library/default retry | duplicate load | central retry governance |
| Missing tenant header | context propagation bug | cross-tenant/security failure | mandatory interceptor |
| Missing trace context | instrumentation gap | broken traces | standard propagator |
| Generated DTO leak | generated model used everywhere | coupling | adapter mapper |
| Fan-out explosion | loop around client call | downstream overload | batch/bulkhead/rate limit |
19. Debugging Playbook
When outbound declarative client fails, inspect in this order:
- What interface method was called?
- What base URL was resolved?
- What final method/path/query/header/body was sent?
- Was the request encoded correctly?
- Was auth/tenant/trace/correlation header injected?
- Did the request leave the process?
- Did DNS/TLS/connection fail before HTTP response?
- What status code came back?
- Was response body decoded or error-decoded?
- Was retry attempted?
- Was timeout from client, gateway, mesh, or downstream?
- Did circuit breaker/bulkhead/rate limit affect the call?
- Is there a downstream trace span?
- Is the failure isolated to one tenant/environment?
20. PR Review Checklist
Review declarative HTTP client changes for:
- interface name and package placement
- HTTP method semantics
- path/version compatibility
- query/header/body annotation correctness
- content type and accept headers
- DTO compatibility
- encoder/decoder configuration
- error decoder behavior
- timeout policy
- retry policy
- resilience wrapper
- request interceptors
- auth/tenant/correlation/trace propagation
- idempotency key if operation mutates state
- request signing if required
- generated client drift
- observability metrics/tracing/logging
- tests for success, 4xx, 5xx, timeout, malformed body
- integration test against mock/downstream contract
21. Internal Verification Checklist
For CSG Quote & Order or any enterprise codebase, verify rather than assume:
- Is Retrofit used?
- Is OpenFeign used?
- Is Jersey Client used instead?
- Are clients generated from OpenAPI?
- Which transport is underneath declarative clients?
- Where are base URLs configured?
- Are timeouts mandatory?
- Is there a platform standard for retry/circuit breaker?
- Is Resilience4j or equivalent used?
- Are request interceptors standardized?
- How are tenant headers propagated?
- How are correlation/trace headers propagated?
- Is error mapping centralized?
- Are downstream contracts versioned?
- Are generated DTOs allowed outside adapter layer?
- Are outbound client metrics standardized?
- Are sensitive headers/body fields redacted?
- Is there a local mock/stub strategy?
22. Senior Takeaway
Retrofit and OpenFeign are not just convenience libraries.
They are abstraction layers over remote failure.
The senior-level concern is not whether an interface looks clean.
The concern is whether the hidden runtime behavior is:
- explicit
- testable
- observable
- bounded by timeouts
- protected by resilience policy
- compatible with contract governance
- safe for tenant/security context
- isolated from domain logic
- easy to debug during incident
A declarative client is production-ready only when the team can answer:
What HTTP request is actually sent?
What happens when it fails?
Who owns the contract?
How is it observed?
How is it safely changed?
You just completed lesson 81 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.