On this page
This failure looked like an annotation problem:
java.net.ProtocolException: Invalid HTTP method: PATCH
at java.net.HttpURLConnection.setRequestMethod(...)
at feign.Client$Default.execute(...)
@PatchMapping was correct. The abstraction below it was not the transport I thought the application was using. The stack trace showed Feign.Client$Default and HttpURLConnection, which made the failure a client-boundary problem rather than a controller problem.
A robust fix needs more than adding an OkHttp dependency. It must make the concrete transport explicit, verify the bytes that leave the process, and define timeouts, mutation retry policy, idempotency, error mapping, and log redaction together.
I tested that boundary with Spring Boot 4.1, Spring Cloud 2025.1.2, OpenFeign, OkHttp, and MockWebServer.
Trace the abstraction downward
When a declarative HTTP call fails, inspect these layers in order:
| Layer | Question |
|---|---|
| Java method | Are path, verb, headers, and body declared correctly? |
| Feign contract | Did Spring MVC annotations become the intended request template? |
| Feign client bean | Which feign.Client implementation was injected? |
| Transport | Does the concrete client support the method, TLS, proxy, and timeout behavior? |
| Network/upstream | What request arrived, and what response or delay occurred? |
| Application boundary | How are conflicts, timeouts, and retries represented to callers? |
The stack trace is evidence. If it names Client$Default, changing an unrelated encoder or controller is unlikely to solve a transport method failure.
Also verify the dependency graph. Spring Boot and Spring Cloud versions must come from compatible release trains; mixing arbitrary Feign artifacts can produce a configuration that compiles but does not activate the intended client.
Make the declarative contract narrow
The client describes an inventory mutation:
@FeignClient(
name = "inventory",
url = "${inventory.base-url}",
configuration = InventoryClientConfiguration.class
)
interface InventoryClient {
@PatchMapping(
path = "/inventory/{sku}",
consumes = "application/json"
)
InventoryResponse adjust(
@PathVariable String sku,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization,
@RequestBody InventoryAdjustment request
);
}
record InventoryAdjustment(int delta) {}
record InventoryResponse(String sku, int available) {}
The interface contains the wire contract. Application code calls a gateway instead of depending on Feign response types or exceptions directly.
Register the transport explicitly
Relying on a property to discover an optional transport is easy to misconfigure. The experiment creates the OkHttp transport and exposes the exact Feign client bean:
@Bean
feign.Client inventoryTransport() {
okhttp3.OkHttpClient transport =
new okhttp3.OkHttpClient.Builder()
.connectTimeout(Duration.ofMillis(300))
.readTimeout(Duration.ofMillis(500))
.callTimeout(Duration.ofSeconds(1))
.build();
return new feign.okhttp.OkHttpClient(transport);
}
This is an application boundary, so explicit wiring is useful. It removes ambiguity about which HTTP stack will execute PATCH.
The timeouts have different meanings:
| Timeout | Bounds |
|---|---|
| Connect | Establishing the connection |
| Read | Waiting for response bytes |
| Call | The entire exchange, including connection and body |
A timeout is not a latency SLO. It is a resource and failure boundary. Set it from downstream behavior and the caller’s total deadline, not from a copied default.
Do not retry a mutation blindly
After a timeout, the caller may not know whether the upstream applied the PATCH. An automatic retry can decrement inventory twice.
The experiment disables Feign retries:
@Bean
Retryer inventoryRetryer() {
return Retryer.NEVER_RETRY;
}
The gateway requires a stable command ID and sends it as an idempotency key:
public InventoryResult adjust(
String sku,
int delta,
String commandId,
String serviceToken
) {
Objects.requireNonNull(
commandId,
"a stable commandId is required for mutation idempotency"
);
InventoryResponse response = client.adjust(
sku,
commandId,
"Bearer " + serviceToken,
new InventoryAdjustment(delta)
);
return new InventoryResult(response.sku(), response.available());
}
An idempotency header helps only if the upstream stores the key and the resulting outcome atomically. The client cannot declare a mutation safe by itself.
For reads, bounded retries may be acceptable when the remaining deadline, backoff, and load amplification are understood. For writes, start with no automatic retry and add one only after end-to-end idempotency is proven.
Map upstream semantics at the gateway
A 409 response has business meaning in this API. The Feign error decoder translates it into an application exception:
@Bean
ErrorDecoder inventoryErrors() {
return (methodKey, response) -> response.status() == 409
? new InventoryConflictException(
"inventory version conflict")
: new ErrorDecoder.Default()
.decode(methodKey, response);
}
The rest of the application should not need to parse Feign exception messages. The gateway can map:
- 400 to a request-contract defect or caller error;
- 401/403 to service authentication or authorization failure;
- 404 to domain absence when that is part of the upstream contract;
- 409 to a version/idempotency conflict;
- 429/503 to controlled availability failure with retry metadata;
- timeout to an ambiguous outcome for a mutation.
Preserve the upstream status and correlation metadata in structured diagnostics, but do not return internal response bodies blindly to clients.
Verify the actual request
A context test with MockWebServer records the request at the transport boundary:
upstream.enqueue(new MockResponse()
.setResponseCode(200)
.addHeader("Content-Type", "application/json")
.setBody("{\"sku\":\"MED-42\",\"available\":11}"));
InventoryResult result = gateway.adjust(
"MED-42", -1, "cmd-018", "secret-token");
RecordedRequest request = upstream.takeRequest(1, TimeUnit.SECONDS);
assertThat(request.getMethod()).isEqualTo("PATCH");
assertThat(request.getHeader("Idempotency-Key")).isEqualTo("cmd-018");
assertThat(request.getHeader("Authorization"))
.isEqualTo("Bearer secret-token");
assertThat(request.getBody().readUtf8())
.isEqualTo("{\"delta\":-1}");
assertThat(result.available()).isEqualTo(11);
This proves the annotation, encoder, headers, transport, and decoder as one path. A unit test of the interface cannot do that.
Two additional tests protect failure semantics:
- A 409 response becomes
InventoryConflictException. - An upstream delayed by 800 ms exceeds the 500 ms read timeout and produces exactly one recorded request.
The request-count assertion is critical. It proves the timed-out mutation was not retried invisibly.
Log the boundary without leaking it
Feign logging can expose Authorization headers and bodies. The experiment uses BASIC logging, which records method, URL, status, and timing without full payloads.
In production I would emit structured metrics and traces for:
- client name, normalized route, method, status class, and outcome;
- latency and timeout phase;
- retry attempt count;
- circuit-breaker state when present;
- a correlation or trace ID.
Do not use SKU, user ID, raw URL, or exception text as unbounded metric labels. Redact credentials and sensitive bodies in logs and traces.
A practical debugging sequence
When an HTTP client method fails:
- Read the deepest relevant stack frame and identify the concrete transport.
- Inspect the resolved dependency graph and compatible Spring release train.
- Make the
feign.Clientbean explicit. - Reproduce against a recording server and assert method, path, headers, and body.
- Test timeout and response mapping, not only the 200 path.
- Prove mutation idempotency before enabling retries.
- Keep credentials and bodies out of client logs.
- Monitor the client boundary with bounded labels and traces.
The PATCH exception was useful because it exposed a hidden dependency boundary. The durable solution is not “use OkHttp whenever PATCH fails.” It is to treat outbound HTTP as a production interface with an explicit transport and tested failure semantics.
The primary references are the Spring Cloud OpenFeign documentation, OpenFeign project documentation, OkHttp documentation, and the HTTP PATCH specification, RFC 5789.