Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Design document for the new HTTP API #2971

Merged
merged 16 commits into from
Apr 21, 2023
Merged
248 changes: 248 additions & 0 deletions docs/design/018-new-http-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
# New HTTP API

## Authors
The k6 core team

## Why is this needed?

The HTTP API (in k6 <=v0.43.0) used by k6 scripts has many limitations, inconsistencies and performance issues, that lead to a poor user experience. Considering that it's the most commonly used JS API, improving it would benefit most k6 users.

The list of issues with the current API is too long to mention in this document, but you can see a detailed list of [GitHub issues labeled `new-http`](https://github.com/grafana/k6/issues?q=is%3Aopen+is%3Aissue+label%3Anew-http) that should be fixed by this proposal, as well as the [epic issue #2461](https://github.com/grafana/k6/issues/2461). Here we'll only mention the relatively more significant ones:

* [#2311](https://github.com/grafana/k6/issues/2311): files being uploaded are copied several times in memory, causing more memory usage than necessary. Related issue: [#1931](https://github.com/grafana/k6/issues/1931)
* [#857](https://github.com/grafana/k6/issues/857), [#1045](https://github.com/grafana/k6/issues/1045): it's not possible to configure transport options, such as proxies or DNS, per VU or group of requests.
* [#761](https://github.com/grafana/k6/issues/761): specifying configuration options globally is not supported out-of-the-box, and workarounds like the [httpx library](https://k6.io/docs/javascript-api/jslib/httpx/) are required.
* [#746](https://github.com/grafana/k6/issues/746): async functionality like Server-sent Events is not supported.
* Related to the previous point, all (except asyncRequest) current methods are synchronous, which is inflexible, and doesn't align with modern APIs from other JS runtimes.
* [#436](https://github.com/grafana/k6/issues/436): the current API is not very friendly or ergonomic. Different methods also have parameters that change places, e.g. `params` is the second argument in `http.get()`, but the third one in `http.post()`.


## Proposed solution(s)

### Design

In general, the design of the API should follow these guidelines:

- It should be familiar to users of HTTP APIs from other JS runtimes, and easy for new users to pick up.

As such, it would serve us well to draw inspiration from existing runtimes and frameworks. Particularly:

- The [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), a [WHATWG standard](https://fetch.spec.whatwg.org/) supported by most modern browsers.
[Deno's implementation](https://deno.land/manual/examples/fetch_data) and [GitHub's polyfill](https://github.com/github/fetch) are good references to follow.

This was already suggested in [issue #2424](https://github.com/grafana/k6/issues/2424).

- The [Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API), a [WHATWG standard](https://streams.spec.whatwg.org/) supported by most modern browsers.
[Deno's implementation](https://deno.land/[email protected]/examples/fetch_data#files-and-streams) is a good reference to follow.

There's a related, but very old [proposal](https://github.com/grafana/k6/issues/592) before the Streams API was standardized, so we shouldn't use it, but it's clear there's community interest in such an API.

Streaming files both from disk to RAM to the network, and from network to RAM and possibly disk, would also partly solve our [performance and memory issues with loading large files](https://github.com/grafana/k6/issues/2311).
imiric marked this conversation as resolved.
Show resolved Hide resolved

- Native support for the [`FormData` API](https://developer.mozilla.org/en-US/docs/Web/API/FormData).

Currently this is supported with a [JS polyfill](https://k6.io/docs/examples/data-uploads/#advanced-multipart-request), which should be deprecated.

- Aborting requests or any other async process with the [`AbortSignal`/`AbortController` API](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal), part of the [WHATWG DOM standard](https://dom.spec.whatwg.org/#aborting-ongoing-activities).

This is slightly out of scope for the initial phases of implementation, but aborting async processes like `fetch()` is an important feature.

- The Fetch API alone would not address all our requirements (e.g. specifying global and transport options), so we still need more flexible and composable interfaces.

One source of inspiration is the Go `net/http` package, which the k6 team is already familiar with. Based on this, our JS API could have similar entities:

- `Dialer`: a low-level interface for configuring TCP/IP options, such as TCP timeouts, keep-alive duration, DNS, and IP version preferences.

- `Transport`: interface for configuring HTTP connection options, such as proxies, TLS, HTTP version preferences, etc.

It enables advanced behaviors like intercepting requests before they're sent to the server.

- `Client`: the main entrypoint for making requests, it encompasses all of the above options. A k6 script should be able to initialize more than one `Client`, each with their separate configuration.

In order to simplify the API, the creation of a `Client` should use sane defaults for `Dialer` and `Transport`.

There should be some research into existing JS APIs that offer similar features (e.g. Node/Deno), as we want to offer an API familiar to JS developers, not necessarily Go developers.

- `Request`/`Response`: represent objects sent by the client, and received from the server. In contrast to the current API, the k6 script should be able to construct `Request` objects declaratively, and then reuse them to make multiple requests with the same (or similar) data.

- All methods that perform I/O calls must be asynchronous. Now that we have `Promise`, event loop and `async`/`await` support natively in k6, there's no reason for these to be synchronous anymore.

- The API should avoid any automagic behavior. That is, it should not attempt to infer desired behavior or options based on some implicit value.

We've historically had many issues with this ([#878](https://github.com/grafana/k6/issues/878), [#1185](https://github.com/grafana/k6/issues/1185)), resulting in confusion for users, and we want to avoid it in the new API. Even though we want to have sane defaults for most behavior, instead of guessing what the user wants, all behavior should be explicitly configured by the user. In cases where some behavior is ambiguous, the API should raise an error indicating so.


### Implementation

Trying to solve all `new-http` issues with a single large and glorious change wouldn't be reasonable, so improvements will undoubtedly need to be done gradually, in several phases, and over several k6 development cycles.

With this in mind, we propose the following phases:

#### Phase 1: create initial k6 extension

**Goals**:

- Implement a barebones async API that serves as a proof-of-concept for what the final developer experience will look and feel like.
The code should be in a state that allows it to be easily extended.

By barebones, we mean:

- The `Client` interface with only one method: `request()`, which will work similarly to the current `http.asyncRequest()`.

For the initial PoC, it's fine if only `GET` and `POST` methods are supported.

It's not required to make `Dialer` and `Transport` fully configurable at this point, but they should use sane defaults, and it should be clear how the configuration will be done.
codebien marked this conversation as resolved.
Show resolved Hide resolved

- This initial API should solve a minor, but concrete, issue of current API. It should fix something that's currently not possible and doesn't have a good workaround.

Good candidates: [#936](https://github.com/grafana/k6/issues/936), [#970](https://github.com/grafana/k6/issues/970).
Copy link
Contributor

Choose a reason for hiding this comment

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

If they are confirmed, do you plan to include the concrete proposal for them in this document?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

#936 would be fixed by the version property.

h2c could be supported via another property, so maybe:

  const client = new Client({
    version: 2,
    insecure: true,
  });

Though this would mean that you couldn't pass an already connected socket, or it would force a reconnection. 🤔 Not sure, we need to decide how to handle the whole Socket/Transport API, and if we're implementing that first, or exposing it later. Once we have a better idea of what we're doing there, making h2c configurable would be a relatively minor addition.

But to answer your question: yes, we should have proposals for issues we plan to work on in phase 1 before merging this PR.

Copy link
Member

Choose a reason for hiding this comment

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

From what I know, HTTP version negotiation happens at a lower level, so I am not sure if the Client is the place where we should have a version property 🤔 On the network or transport level is probably more appropriate 🤔 Though I am not sure if we should even specify these things in this design doc, a PoC seems like a better place to figure them out

Copy link
Contributor Author

Choose a reason for hiding this comment

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

HTTP version negotiation happens at a lower level

Right, it can happen during TLS negotiation, as part of ALPN. But HTTP/1.1 connections can also be upgraded via a header. I'm conflicted about it as well, though it definitely should be somewhere lower level as well.

Maybe part of TLS.connect()?

import { TLS } from 'k6/x/net';
const tlsSocket = await TLS.connect('10.0.0.10:443',
  { alpn: [ 'h2', 'http/1.1' ] });
const client = new Client({
  socket: tlsSocket,
});

This way you could force HTTP/1.1 over TLS, even if the server supports HTTP/2. This could be simplified to a versions array, instead of alpn.

Since h2c must be negotiated via an Upgrade header, and can only be done without TLS, then something like the insecure flag above on the Client itself would be the way the go, in which case we should also keep the version property. It wouldn't make sense to specify that as a, say, TCP.open() option.

But I agree that we don't need to agree on every single detail to start working on the PoC. We can iron out these details as we make progress.

Copy link
Contributor

Choose a reason for hiding this comment

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

Just to note that the UPGRADE header is only used for (in practice) websockets and h2c without "prior knowledge".

The usuall http2 upgrade happens in practice in the tls handshake and h2c with prior knowledge just starts talking http2 directly.

http3 being over UDP means that the upgrade is basically a completely new connection. Except again if "prior knowlege" is used in which case it is still on a new connection, it just skips the first one ;).

I am not certain where this should be configured ... or even if it should be one place or if it should be solved with some more involved setup.

Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
Good candidates: [#936](https://github.com/grafana/k6/issues/936), [#970](https://github.com/grafana/k6/issues/970).
This initial API must solve a minor, but concrete, issues of the current API. It fixes something that's currently not possible and doesn't have a good workaround as [#936](https://github.com/grafana/k6/issues/936) and [#970](https://github.com/grafana/k6/issues/970).

I think we can promote them.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In 0fde824, I added TLS options to TCP.open(), instead of having TLS be a separate k6/x/net class.


- Features like configuring options globally, or per VU or request, should be implemented.
Deprecating the `httpx` library should be possible after this phase.


**Non-goals**:

- We won't yet try to solve performance/memory issues of the current API, or implement major new features like data streaming.

codebien marked this conversation as resolved.
Show resolved Hide resolved

#### Phase 2: work on major issues

**Goals**:

- Work should be started on some of the most impactful issues from the current API.
Issues like high memory usage when uploading files ([#2311](https://github.com/grafana/k6/issues/2311)), and data streaming ([#592](https://github.com/grafana/k6/issues/592)), are good candidates to focus on first.


#### Phase 3: work on leftover issues

**Goals**:

- All leftover `new-http` issues should be worked on in this phase.
**TODO**: Specify which issues and in what order should be worked on here.
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess this is the part with all the socket and dns stuff?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's not clear yet, so we should decide how to handle that.

The DNS lib? Sure, it can be done here, as it's not critical. For the socket lib, I'm not so sure, and think it should be done earlier, maybe even in phase 1.

This is partly a reply to your top comment, but I think we should structure the Go API in such a way that it mirrors the JS API, so that exposing things like the Sockets API would just be a matter of adding JS bindings to it. If we don't think about how the API will look from the JS side and drive the implementation based on that, then we might end up with a state that uses Go semantics and is difficult to later expose to JS.

This is why I think we should start with the lower level APIs the HTTP API depends on first, and then build on top of it. I added an introduction to the Design section that touches on why this is important.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, forgot to mention that phase 3 would essentially be for features we consider lower priority. The high priority features would be done in phases 1 and 2, but we should decide which features should be done when. The spreadsheet I shared attempts to do this, so we can start there.


- The extension should be thoroughly tested, by both internal and external users.


#### Phase 4: expand, polish and stabilize the API

**Goals**:

- The API should be expanded to include all HTTP methods supported by the current API.
For the most part, it should reach feature parity with the current API.

- A standalone `fetch()` function should be added that resembles the web Fetch API. There will be some differences in the options compared to the web API, as we want to make parts of the transport/client configurable.

Internally, this function will create a new client (or reuse a global one?), and will simply act as a convenience wrapper over the underlying `Client`/`Dialer`/`Transport` implementations, which will be initialized with sane default values.

- Towards the end of this phase, the API should be mostly stable, based on community feedback.
Small changes will be inevitable, but there should be no discussion about the overall design.
Comment on lines +467 to +479
Copy link
Contributor

Choose a reason for hiding this comment

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

I kind of don't see the benefit of any of this especially as part of this proposal.

While some or all of those changes (won't call them improvements as I personally don't like them), might be added. None of those IMO are a good reason to not make the API generally available.

Copy link
Contributor Author

@imiric imiric Mar 29, 2023

Choose a reason for hiding this comment

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

Which part are you specifically referring to? All 3 points of the Goals section, or just the Fetch point?

I'm surprised you object to the Fetch API, since you opened #2424. 😉

I don't understand why this is such a hot topic...

First of all, this is not a blocker for making the API generally available. It will be available to anyone who wants to use the extension starting from phase 1. And we now agreed that the extension should be merged into k6 as an experimental module at the end of phase 2.

Secondly, once the main API is implemented, adding a fetch() wrapper on top of it is such a minuscule and trivial part of it, that's it's not even worth debating at this point.

The reason I think it's worth mentioning as a general design goal, even if it's not as important as other aspects of the API (which is why it's in phase 4), is because it would be very convenient to use, similar to how k6/http is now. Most users won't care about configuring clients or changing transport-related settings, and would just want to fire off a quick request. In this sense, I expect fetch() to be the most popular API, since it's familiar and easy to use. A good indicator of usable APIs is to expose complexity gradually and as needed; make things easy to use for newcomers, but flexible enough for people to explore naturally. To quote Alan Kay: "Simple things should be simple; complex things should be possible." ❤️ Since the overall goal of k6 is to deliver the best DX possible, having familiar and friendly wrappers like this aligns well with that mission.

So please suggest which parts of this you would take out, or how you would change it, but I don't think we should remove the Fetch API as a design goal.

Copy link
Contributor

Choose a reason for hiding this comment

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

I would argue the proposal here is about making things possible.

Both we and every user can (and will) extend this API.

But for me adding a bunch of UX improvements and that being not a small part of the specification does not help with the discussion around - it hinders it. I now or whoever has to read a bunch more text and then decide that whoever wrote it and the people who have agreed, meant those as inspirational things instead of as goals that need to be reached by this proposal to be called fulfilled.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Would it help if we split this large proposal into several smaller ones? The scope is quite large already, and we haven't even fleshed out the details of the Sockets or DNS APIs. Splitting this into separate proposals would make each one more manageable, and allow us to prioritize them as a related group. Then the UX improvements, the Fetch API and any such convenience wrappers, could go into one.

I'm not sure where to start with this, so if we agree to do this, any suggestions to move it forward would be appreciated.

Copy link
Member

Choose a reason for hiding this comment

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

At this point, I think we would be better served by proofs of concept. Iterating on code instead of on more lengthy text seems like it would be the more productive way forward.

Copy link
Contributor

@codebien codebien Apr 12, 2023

Choose a reason for hiding this comment

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

Would it help if we split this large proposal into several smaller ones?

@imiric If possible, I would prefer to have one single source of truth and not fragment the information. In the end, the doc sounds still manageable to me.

I think the unique very detailed phase should be the first. All the rest should give us a guideline in terms of vision and roadmap for the long-term. We should re-iterate this doc at the end of each phase and before starting a new one.

I suggest changing phase 4 with something like this:

Suggested change
#### Phase 4: expand, polish and stabilize the API
**Goals**:
- The API should be expanded to include all HTTP methods supported by the current API.
For the most part, it should reach feature parity with the current API.
- A standalone `fetch()` function should be added that resembles the web Fetch API. There will be some differences in the options compared to the web API, as we want to make parts of the transport/client configurable.
Internally, this function will create a new client (or reuse a global one?), and will simply act as a convenience wrapper over the underlying `Client`/`Dialer`/`Transport` implementations, which will be initialized with sane default values.
- Towards the end of this phase, the API should be mostly stable, based on community feedback.
Small changes will be inevitable, but there should be no discussion about the overall design.
#### Phase 4: expand, polish and stabilize the API
**Goals**:
- The API should be expanded to include all features supported by the current API.
For the most part, it should reach feature parity with the current API.
- Include all the major required features not available in the current API: [we can drop here the list of all the issues not included in the previous phases - I expect we will update it after the first phase].
- Towards the end of this phase, the API should be mostly stable, based on community feedback.
Small changes will be inevitable, but there should be no discussion about the overall design.

Copy link
Contributor Author

@imiric imiric Apr 13, 2023

Choose a reason for hiding this comment

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

At this point, I think we would be better served by proofs of concept. Iterating on code instead of on more lengthy text seems like it would be the more productive way forward.

Agreed. I started working on a PoC weeks ago, but Ned then suggested it was better to discuss the design first, so I abandoned it. At this point, it doesn't even feel like we agree on phase 1 of the current proposal, so I don't think we're anymore ready to work on the PoC than we were back then.

The reason to split this proposal into several more focused ones is to address Mihail's feedback that the scope of this proposal has expanded beyond just an HTTP API. And particularly to get rid of any mentions of UX improvements and the Fetch API, which seems to be controversial.

If we don't want to split it, then I guess everyone is fine with most of the sections here being incomplete, and the proposal "tiring to read"? It's frustrating trying to address some feedback, and then getting mixed signals about the way to proceed. 😓

In order to work on the PoC again, does everyone agree with the current phase 1?

That is: we won't be exposing a Sockets API, and network/transport options won't be configurable. The only goal is to expose a Client.request() that fixes a minor issue like #936 or #970. Although, in practice, both #936 and #970 will probably require configuring the transport, so maybe another issue would be a better fit.

If you agree, please approve the PR and let's merge it as is. If not, please suggest improvements to phase 1 only. We can iterate on and flesh out the other phases later.



#### Phase 5: merge into k6-core, more testing

At this point the extension should be relatively featureful and stable to be useful to all k6 users.

**Goals**:

- Merge the extension into k6 core, and make it available to k6 Cloud users.
mstoykov marked this conversation as resolved.
Show resolved Hide resolved

- Continue to gather and address feedback from users, thorough testing and polishing.


#### Phase 6: deprecate `k6/http`

As the final phase, we should add deprecation warnings when `k6/http` is used, and point users to the new API.
Eventually, months down the line, we can consider replacing `k6/http` altogether with the new module.
mstoykov marked this conversation as resolved.
Show resolved Hide resolved


### Examples

- `basic-get.js`:
```javascript
import { fetch } from 'k6/x/net/http';
Copy link
Contributor

Choose a reason for hiding this comment

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

If we implement fetch as a JS polyfill can we combine it in a native module? I guess we could have a wrapper on top of the new-k6-http-client+the polyfill. Am I missing something?

I think this is a nice UX and if we would not able to do it then we should insert it as a risk.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My thinking so far is that the Fetch API would be part of the native Go module. There's really no reason it needs to be a JS library, and it would be a small addition anyway, so it shouldn't matter.


export default async function () {
// Creates a new default Client internally.
const response = await fetch('https://httpbin.test.k6.io/get');
const jsonData = await response.json();
console.log(jsonData);
}
```

- `basic-post.js`:
```javascript
import { fetch } from 'k6/x/net/http';

export default async function () {
await fetch('https://httpbin.test.k6.io/post', {
method: 'POST',
json: { name: 'k6' }, // automatically adds 'Content-Type: application/json' header
});
}
```

- `basic-get-request.js`:
```javascript
import { fetch, Request } from 'k6/x/net/http';

export default async function () {
const request = new Request('https://httpbin.test.k6.io/get', {
headers: { 'Case-Sensitive-Header': 'somevalue' },
// Other options that can also be passed directly to fetch()
});
const response = await fetch(request, {
// Some options that will merge with or override the options specified
// in the Request.
});
const jsonData = await response.json();
console.log(jsonData);
}
```

- `advanced-get.js`:
```javascript
import { Client } from 'k6/x/net/http';

const client = new Client({
proxy: 'https://myproxy',
forceHTTP1: true,
dns: { ... },
// other transport options: h2c, TLS, etc.
});

export default async function () {
const response = await client.get('https://httpbin.test.k6.io/get');
const jsonData = await response.json();
console.log(jsonData);
}
```

- `advanced-post-streaming.js`:
```javascript
import { File } from 'k6/x/file';
import { fetch } from 'k6/x/net/http';

// Will need supporting await in init context
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we have an issue with it? It sounds like a prerequisite for the 3rd phase.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was my attempt at coming up with a File API, and is more of a thought experiment than anything else. #2977 goes into much more detail, and these kinds of issues should be addressed there.

This is inspired by Deno's API. While they do have sync versions of most APIs, it's clear that if we want to adopt streams, we'll need to defer actual reading of files to whatever process needs it. I.e. we can't read the whole file into memory here in the init context, so it's likely that this will open a file handle only, and file.readable will be the stream reference. Whether that can be done without await, or if we'll need to support this in the init context, is an open question.

Copy link
Contributor

Choose a reason for hiding this comment

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

I thought I commented this ... but :(

(top level await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await#top_level_await) is a ES2022 feature that made all the module resolutions in ESM asynchrnous. Which is partly what makes them a bit more involved and is what blocked them on async/await support in goja.

As soon as ESM is merged this will work as well. While I am in practice blocked specifically on fixing basically this functionality - I do need it for the basic implementation, so ... yeah - once ESM is native it will work.

2 workarounds are possible until then:

  1. we can make the already existing wrapper async - this likely will have ... strange behaviours, but we already need to fix those around ESM discussion: open and require are relative to? #2674
  2. The workaround for before this was a thing was to make an inline async lambda and call it. This has some usability problems, but will likely be okay for a release or two, given that I expect this functionality will not be released before ESM is on the final stretch.

const file = await File.open('./logo.svg'); // by default assumes 'read'

export default async function () {
await fetch('https://httpbin.test.k6.io/post', {
method: 'POST',
body: file.readable,
});
}
```


## Potential risks

* Long implementation time.

Not so much of a risk, but more of a necessary side-effect of spreading the work in phases, and over several development cycles. We need this approach in order to have ample time for community feedback, to implement any unplanned features, and to make sure the new API fixes all existing issues.
Given this, it's likely that the entire process will take many months, possibly more than a year to finalize.


## Technical decisions

TBD after team discussion. In the meantime, see the "Proposed solution(s)" section.