Design Considerations For A Swift HTTP Client

In addition to the ongoing discussions of the lowest level networking types like IP address, we'd like to start discussions around the very top of the stack, the HTTPClient. This is a large design space with many existing solutions in Swift and across all programming languages. For the purposes of this thread we'd like to cover some specific topics. Feel free to bring up additional questions and we can continue discussion and spin off other threads, which will be linked back here. Current topics include:

  • What is HTTPClient, how is it created, and how is it used?
  • What is the top level interface to performing a request?
  • How are requests configured?
  • How do users handle responses?

There is currently a prototype implementation of an existential HTTPClient that wraps various platform HTTP libraries in swift-http-api-proposal. Please note that this library is an experimental prototype created to explore various API designs, not a final proposal, but it contains a functional cross platform implementation that illustrates the possible shape of a Swift HTTP library.

HTTP Client

Fundamentally, an HTTP client is a state isolation boundary where the common state for some set of requests is kept. This (possibly) includes things like the connection pool and various bits of storage like caches or cookies.

The nature of the top-level HTTPClient type is a big part of a user's initial impression of the library. Is it a protocol or a concrete type? Is it a class, a struct, or an actor? The experimental API uses an HTTPClient protocol, as well as the concrete DefaultHTTPClient class which conforms to the protocol and uses various platform APIs to actually make requests.

Performing a request

At its simplest, what does it look like to perform a request? Can we generalize all requests into a single interface or are there multiple fundamental shapes we must support? Currently, the prototype HTTPClient protocol has a single requirement:

func perform<Return: ~Copyable>(
  request: HTTPRequest,
  body: consuming HTTPClientRequestBody<Writer>?,
  options: HTTPRequestOptions,
  responseHandler: (HTTPResponse, consuming Reader) async throws -> Return
) async throws -> Return

Configuring requests

As you can see in the above example, request options are passed in the perform method as HTTPRequestOptions, which is actually an associatedtype from the client protocol conforming to an HTTPCapabilities.RequestOptions protocol that currently has no requirements. This allows conformers to provide their own options. However, there is a broader question here: do options exist only at the perform level, or can they exist at the HTTPClient level as well? How are they split? How do they override each other? What values are necessary to configure a request?

Creating a body

perform uses an HTTPClientRequestBody with an abstract Writer provided by the conformer. This enables streaming bodies in requests with APIs like

body: .seekable { offset, writer in
  var requestBody = // produce the request body
  try await writer.write(buffer: &requestBody)
}

What should users be able to do with the body of the request?

Handling responses

As you can see by the perform, returning a single Return value is one possible shape, where the consuming Reader allows callers to perform their parsing on an AsyncReader of UInt8 values. This async stream will likely be a fundamental construct of any Swift network API, but is this the appropriate, foundational interface? Are there requests that need something more? Can we build all of the needed higher level API on this function?

These are just some of the many questions we must explore in this space as Swift builds out its own networking capabilities. We look forward to your feedback.

6 Likes

If I were to write a function like this which takes an arbitrary client…

func fetchUsers(client: some HTTPClient) {
    client.perform(...)
}

…how would I specify a value for options? Would you expect there to be overloads on HTTPClient that construct an empty set of options?

Would it be ergonomic for individual clients to expose an option that generates a copy of the client with a particular option set as an alternative for having per-request options? e.g.

let client = URLSessionHTTPClient(...)
try await client
  .allowingExpensiveNetworkAccess()
  .perform(request: ...)

Sketching out an alternate shape for constructing a request based on chaining:

try await client.perform {
  // actually 'Request<URLSessionHTTPClient>'
  HTTPRequest.get("https://forums.swift.org")
    .header(.accept, .png) // uses UTTypes?
    .header(.authorization, .bearer("abc123"))
    .allowingExpensiveNetworkAccess() // custom extension on Request<URLSessionHTTPClient>
    .body(Data(...))
} /* [todo: response API] */
2 Likes

In my experience it is very ergonomic. Our internal http library is build for this kind of composition which allows us to inject clients that are already prepared in terms of authentication, tracing, logging and so on.

In that library clients are similar to Views in SwiftUI, so a client wrapping (and modifying) a client is again a client. We also have something similar to EnvironmentValues so inner clients can read values declared by outer clients. Sounds like overkill, but it allowed us to follow significant changes of how backend endpoints evolved over the years, with minimal churn on our side, because adapting to endpoint changes only meant switching out the injected base client.

2 Likes

So IMO I think there are two levels of APIs that need to be decided. There needs to be a low-level API, like the one currently defined that allows highly performant and configurable requests that can handle all types of requests, streaming (both sending and receiving), different HTTP versions, etc. Then on top of that, as extensions to the protocol, we can define APIs that 90% of people will use, like sending a body of Data and decoding synchronously etc.

In terms of configuration I think we need to configure probably at the global level (think trust stores, connection pool sizing) and have per-request options that can override any global options, like a timeout for something with a very large streaming body etc

I would also like to see testing called out as a specific, first-class, use case. The use of the protocol makes this possible. We do a very similar thing in Vapor with our Client protocol that you can just switch out for testing.

In terms of usage, I think most people will probably just want a shared client they don't have to think too hard about, like URLSession.shared or HTTPClient.shared like AHC. We do need to discuss how this would work for both a distribution point of view and for things like scripts (though if distribution is just "here's a package" that answers all7 the questions).

Finally I think it would be good to set the top-level goals for the client. Something like performance (streaming first, low-memory overhead), safety (make it impossible to consume a response twice, explicitly handle all error cases) and ease of use (nice bits of syntactic sugar on top of the base API(s) that make it really easy to use).

9 Likes

A protocol vs a concrete type

Unless the Swift project commits to providing an HTTP client implementation for all supported Swift platforms (existing and future), I think we need a protocol as the fundamental type here, and focus most discussion on it.

Considerations for libraries using the HTTP client

The universality of the HTTP client interface is a crucial building block for cross-platform libraries. Today, many libraries self-select their subset of the Swift ecosystem by using a specific HTTP client, despite the fact that all their business logic is fully cross-platform. This HTTP client effort should set this as one of the guiding goals here - it must be possible to write a cross-platform library that uses an HTTP client under the hood. Presumably that HTTP client would be injectable, to also address the testability point that @0xTim brought up and that I agree with.

Now, following this logic, a cross-platform library that uses this HTTP client protocol to perform HTTP requests also needs to have a high-enough confidence in the capabilities of the individual HTTP client implementations. What that means is that behavior such as cookies (sending, receiving, storing), streaming (request and response body), trailer support, all need to be explicitly specified to be present or absent, but NOT undefined. We need to avoid a library needing to perform runtime checks for features, and having no option other than to throw an error if a required feature is missing. Ensuring features can be detected, and required, by the library, at compile time, is what will unlock the ecosystem to start building high-quality, robust, and testable cross-platform libraries.

My final point on this area is about generics. While I think we should give the compiler as much knowledge about implementation as we practically can, to allow deep optimizations, we also need to allow libraries to not be generic over their HTTP client (and instead accept some form of any HTTPClient). While some libraries are purely Swift wrappers for a REST API, for which it'd be reasonable to be generic over the HTTP client, other libraries perform HTTP calls as a more of a side-effect of how they work, and outside of their initializer, we shouldn't leak the details of the HTTP client.

4 Likes

Thanks for reviewing the existing prototypes and writing this up. I want to briefly discuss some of the goals that led to the current shapes:

Structured resource management

The APIs should follow the principles of structured resource management enabled by structured concurrency. This means that when a method returns all of the resources it created are cleaned up. The perform method correctly follows this principle by providing the response in a closure and consuming a body writer.

Compile time HTTP semantics

We wanted to achieve that the HTTP semantics are enforced at compile time. This means that you can only read or write the trailers when you are doing reading or writing the body.

Streaming at the core

Streaming over HTTP became more and more important over the previous years. The most fundamental layer needs to express complete bi-directional streaming with trailers. On top of this layer it is then possible to build simpler convenience interfaces such as .get() or .post()

Support for more than once concrete client

The main goal behind the abstract APIs is that we expect the ecosystem to always have more than one concrete client implementation. While there is strong desire to have one really good cross platform client, Swift is used in many different environments such as WASM or Android where you might want to use clients such as Fetch-based client or OKHTTPClient. The abstract APIs should allow library developers to become agnostic to whatever client is used.

Extension through capabilities

When APIs become generic over a client and the default client only has a simple perform method it is important that the APIs can express requirements on that client such as "Force an HTTP version", "Configure the TLS certificates", etc..

Request-level overrides

Another goal is that on a per-request basis almost everything can be overridden. This avoids users having to create multiple HTTP client instances and instead share one instance wherever possible. However, it is important that there are some configuration options such as connection pool options that are only settable when creating a concrete client.

Putting all these goals together is what led to the existing proposal API. Trying to answer some of the questions that you brought up here @Jon_Shier:

If you are writing an application then you should start to use the concrete types such as HTTP.get(...) only if you write a library or once you wanna add testing to your application should you got to the protocol.

A concrete client can be any of those three. However, in practical terms a struct is hard to implement if there is more than one concurrent user of the client unless you back it by some ref-counted state. An actor is possible but it would most likely mean you serialize all requests onto a single actor, significantly limiting the performance of the client. So the most common choice for a client is a final class that protects its shared state with something like a Mutex.

This is tied to the goal "Streaming at the core". Once you are able to express the most fundamental level of HTTP then you can layer everything else on top.

As per goal "Request-level overrides", we want most options to be configurable at the request level. A concrete client might provide capabilities to pass in a default configuration and decide how the request-level configuration is merged with that. Concrete clients might also expose additional client-level options when creating the client.

Yes, I agree that we need to layer convenience APIs on top of the current API that we expect most people to use. However, I do think we need an even lower-level API for the client that re-presents true wire semantics of HTTP. Right now the HTTPClient protocol has a concrete body type that allows seeking and replaying. This is important for features such as redirect handling or resumable uploads. Supporting these features means that a single perform can issue multiple HTTP requests at the wire level. This is important since most users expect redirects to just work. I personally think that we should have a protocol that represents the wire-level of HTTP and a higher level protocol that represents an "ergonomic" HTTP client. If you have something that can do wire-level HTTP you can build an "ergonomic" HTTP client through composition of middleware.

3 Likes