Skip to content

Apply review feedback to the multi-encoder and multi-decoder surface - #3534

Merged
velo merged 2 commits into
masterfrom
feat/multi-codec-review-followup
Aug 25, 2026
Merged

Apply review feedback to the multi-encoder and multi-decoder surface#3534
velo merged 2 commits into
masterfrom
feat/multi-codec-review-followup

Conversation

@velo

@velo velo commented Aug 21, 2026

Copy link
Copy Markdown
Member

@trumpetinc's review of #3527, applied to both sides of the codec surface. Everything here is still
@Experimental, so this is the moment to move it.

Taken

A multi-codec is itself predicated. MultiEncoder implements PredicatedEncoder and
MultiDecoder implements PredicatedDecoder, accepting whatever any of their delegates accepts,
so one nests inside another. That is the composition you asked for — a library can ship a set as a
single unit:

Feign.builder().encoders(StreamingFeign.encoders(), new JacksonEncoder());

The winning delegate's canEncode is evaluated twice, once by the outer anyMatch and once by the
inner loop. Predicates are already documented as cheap and free of side effects, so that is
acceptable, but it is worth saying out loud.

Nested sets are unfolded in the failure message, so contributing a bundle does not turn it into one
opaque line:

Encoders tried, in order:
  - JacksonEncoder
  - MultiEncoder:
    - StreamingStringEncoder when the return type is a Stream
    - StreamingBytesEncoder when Content-Type is application/octet-stream

DefaultEncoder and DefaultDecoder declare themselves. You were right and I was wrong: the
predicate is not a yes-man, it is exactly what encode/decode handles. This makes raw
passthrough composable, which is genuinely useful — a String body reaching Jackson comes out
quoted, so putting the default first fixes that:

Feign.builder().encoders(new DefaultEncoder(), new JacksonEncoder());

One correction to the predicate you proposed: DefaultEncoder.encode also accepts a null body,
which it sends as no body at all. Dropping that case would break

@RequestLine("POST /orders") void create(Order order);
client.create(null);

so the predicate is bodyType == String.class || bodyType == byte[].class || object == null.
DefaultDecoder gets the same treatment, including 404/204 and a null body, which it handles for
any type.

An empty builder fails at wiring time, IllegalStateException from build(), rather than
producing a codec that can only ever throw on the first request. encoders()/decoders() with no
arguments fail the same way.

Builder.narrow(predicate, codec), so the replace/AND pair sits side by side where the
difference is visible, and the javadoc on of and narrowing now says which is which in one line.
More on that below.

The formRequests() javadoc wording, and a note on the FormEncoder(Encoder) constructor that
chaining belongs in encoders(...) and the delegate is expected to be deprecated once this surface
stops being experimental.

Pushed back on

narrowing is not redundant with add(predicate, encoder). PredicatedEncoder.of replaces
whatever the encoder declares — deliberately, so you can point a GsonEncoder at a vendor content
type it would otherwise refuse. narrowing is the AND. Both are needed. That said, the fact that it
read as redundant is the finding: the names were not carrying the difference. Hence the builder
narrow and the sharper javadoc rather than a rename, since the statics are already published.

The failure message reports Content-Type and Accept, not every header. EncodeException
messages land in logs and the header map routinely holds Authorization. The detail is not lost —
every predicate carries a description and is printed alongside the codec it guards, so a predicate
keyed on an unusual header says so itself:

.add(EncoderPredicate.describedAs("X-Acme-Format is protobuf", ...), protoEncoder)
Unable to encode com.acme.Order (Content-Type: text/plain, Accept: application/json) for POST /orders.
Encoders tried, in order:
  - JacksonEncoder
  - ProtobufEncoder when X-Acme-Format is protobuf
Register an encoder that accepts it, or add a catch-all (EncoderPredicate.any()) last.

The hint wording is softened too, since .any() is an implementation detail for anyone who came in
through encoders(...).

On the decode side the second header is the request's Accept, which is what the caller asked
for.

Not done

DefaultEncoder as a MultiEncoder — you flagged it as an observation rather than a proposal,
and I agree it is one. It trades a ten-line class for a list allocation and makes the failure
message worse.

Centralizing content-type parsing on the parser in #3494 — agreed in principle, but that PR is
still open. The Util helpers are @Experimental, so whichever lands second swaps the internals;
no need to couple them now.

mvn clean install passes.

Signed-off-by: Marvin Froeder <velo.br@gmail.com>

@trumpetinc trumpetinc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@velo review is done. The functional code looks solid to me, most comments are small things about the documentation, one question about StringDecoder.

Super excited to see this getting added to Feign.

import java.lang.reflect.Type;

public class DefaultDecoder extends StringDecoder {
public class DefaultDecoder extends StringDecoder implements PredicatedDecoder {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For completeness, should we make StringDecoder a PredicatedDecoder?

(PS - it is unfortunate that DefaultDecoder extends StringDecoder... inheritance vs composition mistake)

|| response.status() == 204
|| response.body() == null
|| byte[].class.equals(type)
|| String.class.equals(type);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we make StringDecoder a PredictedDecoder, this should check super.canDecode() instead of hard coding the String.class.equals(type) check.

* single unit:
*
* <pre>
* Feign.builder().decoders(StreamingFeign.decoders(), new JacksonDecoder());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to call out that StreamingFeign.decoders() doesn't currently exist (streaming requires breaking changes, so is not part of head at this time).

It may be advisable to say "Given a hypothetical library called StreamingFeign that has a decoders() method that returns a MultiDecoder of it's various decoders, this is how the library's decoders would be added:"

Or maybe cleaner to just not talk about this at all until we have a proper example?

for (PredicatedDecoder decoder : decoders) {
if (decoder instanceof MultiDecoder) {
message.append(indent).append("- MultiDecoder:");
((MultiDecoder) decoder).appendTo(message, indent + " ");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call on the indentation - that will make it very clean to diagnose issues.


/** Builds the multi-decoder. */
/**
* Adds a decoder that already declares itself, narrowed by the given predicate: both it and the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"that already declares itself" is a bit confusing. I think this could be replaced with:

"Adds a Decoder, narrowed by the given predicate. If the Decoder is itself a PredicatedDecoder, the provided predicate is applied in addition to the PredicatedDecoder's existing predicate."

Comment on lines +220 to +221
* .narrow(DecoderPredicate.status(200), new JacksonDecoder())
* .add(new JacksonDecoder())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Example is a little confusing. Should we specify a different decoder type for status=200 ? Maybe change the .add() line to new DefaultDecoder() ?

* single unit:
*
* <pre>
* Feign.builder().encoders(StreamingFeign.encoders(), new JacksonEncoder());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comment as above about StreamingFeign.encoders() not existing.

* <pre>
* MultiEncoder.builder()
* .narrow(EncoderPredicate.contentType("application/vnd.acme+json"), new GsonEncoder())
* .add(new GsonEncoder())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar to comment above in MultiDecoder.narrow() - shold the .add() line call out a different encoder type so the example makes sense?

Signed-off-by: Marvin Froeder <velo.br@gmail.com>
* MultiEncoder.builder()
* .narrow(EncoderPredicate.contentType("application/vnd.acme+json"), new GsonEncoder())
* .add(new GsonEncoder())
* .add(EncoderPredicate.any(), new DefaultEncoder())

@trumpetinc trumpetinc Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For what it's worth, I don't like using EncoderPredicate.any() with DefaultEncoder. The reality is that .any() isn't really a good predicate for any Encoder or Decoder... I suggest:

.add(new DefaultEncoder())

@velo
velo merged commit 22c3b4b into master Aug 25, 2026
4 checks passed
@velo
velo deleted the feat/multi-codec-review-followup branch August 25, 2026 17:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants