Streaming decoder support2 - #3551
trumpetinc wants to merge 23 commits into
Conversation
|
Fixing problems with initial git branch ( #3494 ) |
|
@velo let's try this - I have no idea what I messed up with the git branching on the earlier PR. This PR is as clean as I can make it. I branched from the latest 14.x and made the required changes. CircleCI is already complaining about build problems (certainly nothing to do with this PR itself??): But at least security snyk checked out this time. |
|
@velo this is ready for review. I implemented it as a separate decoder class - if you prefer that this functionality be implemented in DefaultDecoder instead, we can do that - but I'll need you to merge the latest PredicatedDecoder changes into the 14.x branch (DefaultDecoder in 14.x isn't a PredicatedDecoder). |
velo
left a comment
There was a problem hiding this comment.
Thanks for this — streaming decoder support is a real gap. Two structural issues before this can merge, both of which also shrink the diff a lot once fixed:
-
InvocationContext.proceed()infers "don't close the response" from whether the declared return type isCloseable, on the single shared decode path every Feign call goes through. This duplicates a mechanism that already exists:BaseBuilder.doNotCloseAfterDecode(), explicitly documented for "lazy-evaluated constructs," where the custom decoder is responsible for closing. As written, any existing method that returns anyCloseable-implementing type through any decoder (not just the new one) will silently stop having its response closed — a connection-leak risk with no opt-in and no test guarding it. It's also inconsistent with itself: wrap the same return type inTypedResponse<InputStream>andrawTypeis no longerCloseable, so the response gets closed anyway, breaking the exact case this was added for. Please drop theCloseable/noCloseaddition entirely and have streaming callers use the existingdoNotCloseAfterDecode()flag, with the new decoder taking ownership of closing as the existing contract already requires of custom decoders. -
ContentTypeParser.parseContentTypeHeaderreimplementsResponse.charset()(same split-on-;-then-=logic, even the samecontentTypeParmeterstypo) as new public API (ContentTypeResult, with an unusedgetContentType()), and ships with a// TODOadmitting it doesn't implement the full parser spec, with no dedicated test.StringDecoderalready reusesResponse.charset()for this. Please dropContentTypeParser/ContentTypeResultand callresponse.charset()directly in theReaderbranch. If quoted-charset support is a real gap, that's worth fixing inResponse.charset()itself rather than a second parallel implementation.
Smaller cleanup while you're in there:
- The blank-line-only change in
DefaultDecoder.javalooks like unintentional diff noise. - The
.mvn/wrapper/maven-wrapper.propertiesmvnd URL fix looks like an unrelated fix that landed on this branch — worth splitting into its own PR so this one's diff stays reviewable on its own terms. InputStreamAndReaderDecoderTeststartsMockWebServerinstances but doesn't stop them — worth adding@AfterEachcleanup.
Happy to take another look once the two structural points are addressed — the feature itself (an InputStream/Reader-capable decoder) is worth having, it just needs to lean on the existing extension points instead of adding parallel ones.
|
@velo good feedback, thank you. I could use your guidance on one of your requests: doNotCloseAfterDecode is a FeignBuilder method. I do not see how we can make a single decoder set that value. And given the move to MultiDecoder, I am certain that we don't want to force the user to have to create a separate Feign instance for streaming decoders vs others. Potential approaches
Concern with that is that Response is currently immutable. So we are talking about making it mutable - AND requiring a side-effect from the Decoder. Not great.
Downside of this is that we have to change the method signature of Decoder. And because DecodeResult is itself an Object, we have to be a little careful to ensure there are no code paths that don't properly process the DecodeResult. I'm not overly concerned with this downside, but it does need to be considered.
Downside here is that this sort of approach has a way of really cluttering an interface spec. Over time, we wind up adding a bunch of boolean methods to an interface to control different aspects of the processing. Wearing my architecture hat, I am not crazy about this option - even though it is probably the least disruptive.
PreferenceMy preference would be option 2 - it is a better design overall. But it absolutely makes this a v14 change (breaking). So you may prefer option 3 or 4. How you would like me to pursue this? |
|
@velo can you please get the change from master:/feign/.mvn/wrapper/maven-wrapper.properties merged into OpenFeign:14.x? That will be necessary for CI to run on this PR. Thanks. |
I have reverted the change to the maven config file - as soon as the mvn config change is merged from master to 14.x, the CI build will work again. |
|
@velo checking in to see if you have any feedback on: |
|
Option 5 — the Ruling out the others, briefly: Option 2 is the better design in the abstract and I agree with your reasoning, but it breaks every Options 3 and 4 have a problem that isn't obvious until you wire them up. Option 1 makes Two things to tidy up on the current implementation:
If we ever do need a per-decoder override, option 4 is additive on top of this. Option 2 never can be. On the maven wrapper: that one's already done. The mvnd 1.0.6 fix landed on 14.x on Sept 4 via #3557, and master was merged into 14.x on Sept 9 ( |
|
@velo I rebased this PR from 14.x, but it's now showing a massive number of commits and file changes from other merges. I thought I understood git, but I must be doing something really wrong. I'll start over. rgggg. Sorry for the delay. |
aa3ac9e to
41b4581
Compare
|
@velo ok - this is ready for you - sorry for the delay. I have left the original approach as-is (type check on Closable), but added additional handling and tests for TypedResponse with streaming bodies. I think we are now in agreement about the functional implementation. Next, how do you want to handle the structural implementation? Options:
or
|
I added a section to the bottom of the MIGRATION-v14.md document that covers this (and includes examples of how to create custom streaming decoders). I added javadoc to Decoder explaining that the caller is responsible for closing the returned stream. I added javadoc to InputStreamAndReaderDecoder explaining that the caller is responsible for closing the returned stream. Do these cover what you had in mind, or do we need something in the README.md file? I'm assuming that at some point some content from MIGRATION-v14.md is going to make it's way into README.md... |
|
Remaining open item:
|
velo
left a comment
There was a problem hiding this comment.
Thanks for the TypedResponse handling and the docs. Here's my answer on the structural question, plus one regression to fix.
Structure: fold it into DefaultDecoder
Go with option 2. DefaultEncoder already handles File/Path/InputStream, so the decoder should match. InputStream foo() should work on a plain Feign.builder() without users having to find and register a separate class. DefaultDecoder throws on these types today, so this only adds behaviour. Please remove InputStreamAndReaderDecoder so there's only one way to do it. If you need DefaultDecoder to be a PredicatedDecoder on 14.x first, tell me and I'll merge master into 14.x.
Blocking: void bodies are no longer closed when doNotCloseAfterDecode() is set
This PR removes ensureClosed(response.body()) from the isVoidType(returnType) && !decodeVoid branch. With the default closeAfterDecode = true the finally block still closes the body, so nothing changes there. With doNotCloseAfterDecode(), though, shouldClose starts out false, and every void call now leaks its connection. Before this PR that branch always closed the body. Please restore that line and add a test with doNotCloseAfterDecode() plus a void method.
Smaller items
- In
proceed(), theTypedResponsepath checksbodyType instanceof Class<?>while the plain path usesTypes.getRawType. UseCloseable.class.isAssignableFrom(Types.getRawType(bodyType))in both places so they behave the same. - Rename
rslttoresult, and drop the blank line before} finally {. MIGRATION-v14.md:- "Closable" →
Closeable(appears 3 times, including theimplements Closablecode sample). - The example declares
ExampleInterfacebut targetsLargeStreamTestInterface, and the decoder returnsnew ExampleStream(...)where it should beExampleStreamingReturn. - The first code block mixes tabs with trailing whitespace.
- "callers responsibility" → "caller's responsibility".
- The file has no newline at the end.
- Once the decoder lives in
DefaultDecoder, update the example to drop.decoders(new InputStreamAndReaderDecoder(), ...).
- "Closable" →
- A short section in
README.mdon returningInputStream/Reader(and that the caller must close it) would help. The migration guide covers what changed, but new users read the README.
The Decoder javadoc note and the migration section are enough to cover the "buffered Closeable now leaks" trade-off. Thanks!
|
Void types are back to force-closing, regardless of doNotCloseAfterDecode() - I see why that is important (there's nothing for the caller to close responsibly when we return void) - apologies for not thinking about that. Question: The original code does not call close when Tests added to FeignTest:
Other changes:
As time allows, I will move the functionality into the DefaultDecoder and remove InputStreamAndReaderDecoder, adjust tests, and update documentation. |
the body. Fix documentation issues. Fix void type return issues.
|
Streaming decoding has been moved into DefaultDecoder (and that is already a PredicatedDecoder through StringDecoder, so I think that was merged earlier). All tests and documentation have been updated. I added a short section to README.md titled ### Streaming Responses I believe that completes all punchlist items. Please let me know if there is anything else! |
velo
left a comment
There was a problem hiding this comment.
Thanks for working through the last round. Nearly all of it is addressed. Remaining items:
Blocking
- The close decision uses the declared return type, not the returned value (
InvocationContext.proceed(), theCloseable.class.isAssignableFrom(rawType)check). When the decoder returnsnullfor aCloseablereturn type, the caller has nothing to close and the body leaks. For example:InputStream download()with.dismiss404()and a 404 with an HTML body.DefaultDecoderreturnsemptyValueOf(InputStream.class), which isnull, so the connection is never closed. The same applies toTypedResponse<InputStream>and to custom decoders that return null.closableMethodsDoNotCloseBodycurrently asserts this leak. Please base the decision on the result (e.g.!(result instanceof Closeable), or checkresult.body()forTypedResponse), and add a test for the 404 +dismiss404+InputStreamcase. - Commit 91a1b61 has no test showing that the body is closed when
decodethrows for aCloseablereturn type.
Docs
- MIGRATION-v14.md ~L449 still has
LargeStreamTestInterface api = Feign.builder().target(ExampleInterface.class, ...). - The same code block still has trailing whitespace (~L448, L454).
- README.md:120 says "Closable"; it should be "Closeable".
- Please mention in the README that
Logger.Level.FULLbuffers the response, which turns streaming off.
Nits
- The
Decoderjavadoc says "Closeable values", but the behaviour is keyed on the declared type. This goes away once the blocking item is fixed. isResponseBodyClosed()matches "closed" in the exception message, which is fragile.- The streaming tests use
new Random()without a seed.
Adds support for streaming decode for Feign RequestLine methods that return InputStream or Reader.
To use:
Changes
InvocationContextnow leaves the response stream open if the return type from the decoder implementsCloseableInputStreamAndReaderDecoderclass that can be registered with the Feignbuilder.decoder()method. Supports passing the decode request to a delegate if the method return type is notInputStreamorReader. If the return type isReader, the charset of the response Content-Type header is used. If no charset is specified in the header, UTF-8 is assumed.ContentTypeParserutility method for obtaining information from the Content-Type header