[Discussion] NIO-based HTTP Client

I'm not very familiar with the server-side domain, but would love to learn more. I apologize if the answers to these questions are obvious with more domain experience.

Why is this a non-open, non-final class? That is, what about an instance of "HTTPClient" has meaningful identity? Would one want to instantiate multiple instances with the same delegates and with different identity, or do you want a singleton-per-delgate? What kind of shared state (beyond the event loop group) is involved in a client, or is every method call otherwise pure?

Why might one subclass it, or why would you not want to allow one to subclass it?

E.g., you could provide a struct generic over the delegate that stores the event loop group. What avenues of user-extensibility are you going for?

This implies that it is the event loop group that has identity and for whom resource management is salient, rather than the client. Is there something else in the client that requires identity? If the client was a struct that stored the event loop group, then ARC functions normally and tracks the life cycle of the group (if present), invoking the de-init when the last reference goes away. This would avoid extra allocations and indirection for the client itself.

I would say that due to my java background some design decisions I make are not very swift-like :smile:, so any questions and ideas would be of great help!

Why is this a non-open, non-final class? That is, what about an instance of "HTTPClient" has meaningful identity?

No particular reason why this is non-final (probably should be final). I think the closest thing in Foundation is URLSession, which is also a class. When I was designing it, having one shared instance with a new delegate-per-call was what I was aiming for.

What kind of shared state (beyond the event loop group) is involved in a client, or is every method call otherwise pure?

HTTPClient keeps the following state: EventLoopGroup, shutdown state and settings (like certificate settings, timeouts, etc). In future, we might also have connection pool there as well. So I think it makes more sense to have it as a class, that is shared between different parts of the program, maybe event single instance per program, if users do not need different default settings.

Why might one subclass it, or why would you not want to allow one to subclass it?

I see no obvious reason for anyone to subclass it, at least right now. Most customisations would be made around delegates, probably. Though, I might be wrong here.

then ARC functions normally and tracks the life cycle of the group (if present), invoking the de-init when the last reference goes away

As @johannesweiss pointed out, EventLoopGroup potential deinit has to release file resources, so it is not a good idea to use ARC for that, or do you mean something else?

Filed 30

Does this mean you want single global shared instance per combination of fields? That is, client1 == client2 if and only if client1 === client2? Is the an advantage to this? Is there a use case for allowing creating different equivalent but not identical instances?

The reason I'm asking is this sounds like something without its own sense of identity beyond what it stores.

For storing the EventLoopGroup in a struct, if you want to distinguish between a user-provided one (which is managed) and the case where the user didn't provide one, the field would be optional (or use your EventLoopGroupProvider enum, which is isomorphic). If this were the only managed resource on the client, this would effectively inline the "should I ARC this" branch into client code on struct copy, rather than always retaining ARCing client and additionally doing that check on client creation/de-init.

Does this mean you want single global shared instance per combination of fields?

If those fields are settings, maybe not, but if we expose channel pipeline or add a connection pool, then having two instances with almost identical values of the fields could be of use, for example, we have one client with at max 5 connections for service X to limit amount of resources we willing to spend on that service, but 30 connections to other service Y. Or, we can have different pipeline bootstraps, one with additional exotic authentication handler, for example. So, depending on how connection pool is implemented, having a class could be a requirement.

Also, having a class gives us an ability to crash the application if users of the client did not shutdown the event loop (but this could probably be moved to ELG itself?)

Does it makes sense?

I'll experiment with making client a struct to see what the implications could be.

To be clear, there is nothing wrong with using class. I'm just poking at the model a bit to see which were conscious decisions. I tend to see people coming from server-side Java to Swift cargo culting class, and people going to server-side cargo cult struct and protocol. Neither is "correct", it depends on what model you want to give to your users.

What is the scenario when you want to crash? Is this when all instances referencing a ELG go away but the ELG is still live? What else would be referencing the ELG at that point?

4 Likes

I complete agree that java developers would over-use classes. This is exactly why I want to play with it a bit more, to make sure that I indeed picked the right model here (though my favourite java-ism is a struct where all fields are let :smile:).

I think there is definitely state beside ELG that we would want to share between parts of the program. Question is, should we just wrap that state inside another class that we would not expose to the user a-la Log struct from swift-log? This is definitely something I should investigate a bit more.

What is the scenario when you want to crash?

If the client was created with its own ELG, we want to make sure that owner of that instance shuts it down.

1 Like

@Michael_Ilseman thank you for your input!

1 Like

Yeah, I think this should be a class, for two (related) reasons:

  1. A HTTPClient has a lifecycle, ie. you have to shut it down (which then might then shut its EventLoopGroup down) and that means it can't be a value type. Ie. this doesn't work:
var httpClient = HTTPClient(...)
let otherHTTPClient = httpClient
httpClient.shutdown()
otherHTTPClient.get(...) // otherHTTPClient is also dead here
  1. We want to enforce the lifecycle, (by asserting in deinit that the client has been shut down).

Why enforce the lifecycle? We've seen that if you don't enforce it, it never gets shut down which leads to expensive resource leaks.

Should definitely be final IMHO but given that it's non-open anything outside the defining module anyway can't subclass it...

3 Likes

You are right, I was confusing BytesToMessageHandler.

So with a few changes, I got it compiling on NIO1 and all the test passing (if that means anything ;)). Was bitten a bit by the ctx -> context rename at first... And for some reason had to move the extension on Request to another file.

https://github.com/t089/swift-nio-http-client/tree/nio1

Equality is ill-defined on an HTTPClient. The reason that this can't be seen right now is that HTTPClient is an extremely simple structure, but as time goes on it will accrue more state that make it clear that the only meaningful notion of equality is identity.

As an example of the things that HTTPClient will grow over time are a connection pool and a cookie store. While it's potentially meaningful to define equality over a cookie store, there is no meaningful definition of equality over a connection pool, as the only time two connections are equal is if they are literally the same connection (that is, have the same identity).

Incidentally, this is why HTTPClient shouldn't be a struct: it isn't a value in any meaningful sense. Any and all operations on HTTPClient would change its value, meaning every function is mutating, and its only equality definition boils down to the identity of the connections it holds.

In many ways the HTTPClient is an ur-reference-type. It makes no sense to take a copy of an HTTPClient, because to do so would require copying the entire connection pool state, establishing many new TCP connections and replaying all prior authenticated requests in the process.

6 Likes

good job

One quick question without thinking too much about it: Why is this implementation not using RequestResponseHandler from swift-nio-extras? Seems like it was made for this client Request/Response use-case?

1 Like

Simple answer - most of the client was written before RequestResponseHandler was committed. Also, things like redirects and cookie support, in addition to possible connection pooling, multiplexing for HTTP/2, would require greater control over the channel.

@lukasa @johannesweiss @tanner0101 @IanPartridge what do you think about upload body streaming suggestion?

Well, we will need it. Not sure if we need it for version 1.0.0 :slight_smile:.

What about API I proposed? Does it look sane? See: [Discussion] NIO-based HTTP Client - #18 by artemredkin

Thanks @artemredkin, totally missed that post! Looks like a great start, I have two pieces of feedback: a) general upload API feedback b) regarding back-pressure & composition with existing APIs.

Generally about the API

I think we should remove this enum and build all the special cases (ByteBuffer, String, Data) on top of the general version (stream). The API can remain its look and feel by making it public static func data(_ data: Data) -> BodyProvider { ... } but this way we can later on extend it without breaking the public API.

One other point: We should probably support chunked encoding uploads (ie. without passing in size).

It looks like the API supports back-pressure but your example kind of invalidates that thought. Is the idea that a user is only allowed to call writer when the previous writer's future has return succeeded?

Because in your example you do

    for _ in 0..<2 {
        var buffer = allocator.buffer(capacity: 4)
        buffer.writeString("1234")
        _ = writer(.byteBuffer(buffer))
    }

which is obviously fine for 8 bytes but should it maybe have been

let body: HTTPClient.Body = .stream(8) { writer in
        var buffer = allocator.buffer(capacity: 4)
        buffer.writeString("1234")
        return writer(.byteBuffer(buffer)).flatMap {
            var buffer = allocator.buffer(capacity: 4)
            buffer.writeString("1234")
            return writer(.byteBuffer(buffer))
        }
}

?

Back-pressure & composition

I think the upload streaming API has two important use-cases:

  1. uploading an also streamed request body (from swift-nio-http-client itself)
  2. uploading a file

Let's look into how those compose:

Uploading an also streamed request body

Would you provide an implementation of HTTPClientResponseDelegate that fits into the writer that HTTPClient.Body requires? If yes, how do we propagate back-pressure back into the HTTPClientResponseDelegate? Because I think right now we'd have a problem: Let's assume we have two simultaneous requests:

  1. one where we connect to a remote server and we stream the request body, directly into
  2. another request to another server where we stream the response body from (1) into (2)'s request body

So that'd be similar to a proxy use-case but for server to server communication that's probably important. Now if we have this setup and HTTPClientResponseDelegate can stream response body much faster than HTTPClient.Body's writer can stream them into the request body. We would then "balloon" the bytes into memory and sooner or later we'd crash for out-of-memory, no?

(I realise this is more feedback about the download streaming API rather than the upload one because the upload one appears to support back-pressure but the download one doesn't AFAICS).

File IO (using NIO's NonBlockingFileIO)

let body: HTTPClient.Body = .stream(8) { writer in
    return myFileIO.readChunked(fileHandle: myFileHandle,
                                byteCount: size,
                                chunkSize: 128 * 1024,
                                allocator: myChannel.allocator,
                                eventLoop: myChannel.eventLoop) { byteBuffer in
        return writer(.byteBuffer(byteBuffer))
    }

where myFileIO is an instance of NonBlockingFileIO.

Which seems to nicely propagate the back-pressure because NonBlockingFileIO's readChunked doesn't call it's chunkHandler again until the future returned in the previous chunkHandler execution has succeeded.

Is my assumption correct that writer would succeed the future it returns when it's ready for the next writer call? And what does the API do if writer is called before the previous call has succeeded? I imagine the API would buffer the bytes but a well behaved user just shouldn't do that. Accurate?

1 Like

Thanks for the feedback!

I think we should remove this enum and build all the special cases (ByteBuffer, String, Data) on top of the general version ( stream ). The API can remain its look and feel by making it public static func data(_ data: Data) -> BodyProvider { ... } but this way we can later on extend it without breaking the public API.

Good idea, how's this looking:

public extension HTTPClient {
    struct Body {
        var length: Int?
        var provider: HTTPClient.ChunkProvider

        static func byteBuffer(_ buffer: ByteBuffer) -> Body {
            return Body(length: buffer.readableBytes) { writer in
                writer(.byteBuffer(buffer))
            }
        }

        static func stream(length: Int? = nil, _ provider: @escaping HTTPClient.ChunkProvider) -> Body {
            return Body(length: length, provider: provider)
        }

        static func data(_ data: Data) -> Body {
            return Body(length: data.count) { writer in
                var buffer = ByteBufferAllocator().buffer(capacity: data.count)
                buffer.writeBytes(data)
                return writer(.byteBuffer(buffer))
            }
        }

        static func string(_ string: String) -> Body {
            return Body(length: string.utf8.count) { writer in
                var buffer = ByteBufferAllocator().buffer(capacity: string.utf8.count)
                buffer.writeString(string)
                return writer(.byteBuffer(buffer))
            }
        }
    }
}

?
Though do you think It will be a major issue since switch over body will usually be done by us and not via client-facing API?

which is obviously fine for 8 bytes but should it maybe have been

let body: HTTPClient.Body = .stream(8) { writer in
        var buffer = allocator.buffer(capacity: 4)
        buffer.writeString("1234")
        return writer(.byteBuffer(buffer)).flatMap {
            var buffer = allocator.buffer(capacity: 4)
            buffer.writeString("1234")
            return writer(.byteBuffer(buffer))
        }
}

Yes, you are correct.

Would you provide an implementation of HTTPClientResponseDelegate that fits into the writer that HTTPClient.Body requires? If yes, how do we propagate back-pressure back into the HTTPClientResponseDelegate ?

HTTPClientResponseDelegate.didReceivePart is called directly from channelRead, and channelRead is called by NIO itself. In NonBlockingFileIO we are calling read methods, so we control when new bytes are received. How do I implement back-pressure in this case? I need a way to call read myself, not by external trigger. Is there an example I can look at?