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

Make cloud API requests idempotent #1208

Merged
merged 2 commits into from
Oct 25, 2019
Merged

Make cloud API requests idempotent #1208

merged 2 commits into from
Oct 25, 2019

Conversation

cuonglm
Copy link
Contributor

@cuonglm cuonglm commented Oct 21, 2019

When k6 requests to cloud API timeout, k6 will retry the request a
few times. This can be problematic, because the retrying request can
cause user's test start twice.

This PR tries to fix that problem by using a session token for each POST
request. With this token, cloud API can know that the request is the
retried one, and avoid creating new test, and return the old one.

Speaking in other way, k6 POST request to cloud API becomes idempotent.

Fixes #1205

@codecov-io
Copy link

codecov-io commented Oct 21, 2019

Codecov Report

Merging #1208 into master will decrease coverage by <.01%.
The diff coverage is 88.88%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #1208      +/-   ##
==========================================
- Coverage   73.65%   73.65%   -0.01%     
==========================================
  Files         147      147              
  Lines       10641    10656      +15     
==========================================
+ Hits         7838     7849      +11     
- Misses       2344     2346       +2     
- Partials      459      461       +2
Impacted Files Coverage Δ
stats/cloud/client.go 75.96% <88.88%> (+2.39%) ⬆️
core/engine.go 93.77% <0%> (-0.96%) ⬇️
js/modules/k6/html/html.go 85.08% <0%> (-0.06%) ⬇️
lib/testutils/httpmultibin/httpmultibin.go 92.53% <0%> (-0.06%) ⬇️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 5cda323...7452ffc. Read the comment docs.

Comment on lines 222 to 245
func TestSessionToken(t *testing.T) {
sessionToken := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
gotK6SessionToken := r.Header.Get(k6SessionTokenHeader)
if sessionToken == "" {
sessionToken = gotK6SessionToken
} else {
assert.NotEmpty(t, gotK6SessionToken)
assert.Equal(t, sessionToken, gotK6SessionToken)
}
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

client := NewClient("token", server.URL, "1.0")
client.retryInterval = 1 * time.Millisecond
req, err := client.NewRequest(http.MethodPost, server.URL, nil)
assert.NoError(t, err)
req.Header.Set(k6SessionTokenHeader, "xxx")
assert.NoError(t, client.Do(req, nil))

req, err = client.NewRequest(http.MethodGet, server.URL, nil)
assert.NoError(t, err)
assert.NoError(t, client.Do(req, nil))
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Not certain what this test adds that the others you have changed don't already do ...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@mstoykov this test for 2 case:

  • POST request with user supplies session token success.
  • Non-POST request success without session token.

Copy link
Contributor

Choose a reason for hiding this comment

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

  1. it doesn't test that the token that is set manually is the one that is used
  2. it doesn't test that the Non-POST request don't send a token
    So in both cases those are tested by other tests already

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@mstoykov Fair point, but I think it just does mean that the test does not do the actual meaningful test, not mean both cases those are tested by other tests already

I'm sure because you can check code cov log, I add the test above because code cov complains about missing of 2 above cases.

Anyway, updated PR coming.

Comment on lines 86 to 91
if req.Method != http.MethodPost {
return nil
}
if req.Header.Get(k6SessionTokenHeader) != "" {
return nil
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think either of those or necessary:

  1. This can be used for all requests not only POSTs
  2. I don't see why calling setSessionToken should not overwrite an old token (maybe the tests were failing
  3. If each of 1 or 2 is not true we can just not call setSessionToken inside Client.Do but insted in Client.CreateTestRun

Copy link
Contributor Author

@cuonglm cuonglm Oct 21, 2019

Choose a reason for hiding this comment

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

@mstoykov

This can be used for all requests not only POSTs

Yes, but idempotency should be applied for POST only. There's nothing you should do with other requests. For k6, generating wasted HTTP header, which backend does not handle increase both bandwidth and load for our cloud API server

I don't see why calling setSessionToken should not overwrite an old token (maybe the tests were failing

The point is that if the caller can specify its session token id, instead of relying on our generating one. See TestSessionToken for example

If each of 1 or 2 is not true we can just not call setSessionToken inside Client.Do but insted in Client.CreateTestRun

Yes, but it's not well design to just handle specific case for CreateTestRun. In future, if we introduce more Post endpoint which need idempotency, this design works for all of them.

Copy link
Contributor

Choose a reason for hiding this comment

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

The problem is that ... literally we have 1 POST we need this for ... and while there is possibility we might add it to others, this is not the case today and I find it unlikely.
Also the method name is ... lying it's not just setting a SessionToken, it's setting only if ... some conditions are met.
To be honest I think generateSessionToken and k6SessionTokenHeader should become a public use them in CreateTestRun and let people if want to to set them in a custom request and send it ... which is also unlikely use case but still ... it would be possible.
If we ever want to do it for all Posts I would argue that the if method == POST should be in the body of the client.Do not in some function

Copy link
Contributor Author

@cuonglm cuonglm Oct 21, 2019

Choose a reason for hiding this comment

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

If we ever want to do it for all Posts I would argue that the if method == POST should be in the body of the client.Do not in some function

Fair point, this change and with satori uuid, the code looks much cleaner

Comment on lines 228 to 233
sessionToken, err := uuid.NewV4()
if err != nil {
return "", err
}

return sessionToken.String(), nil
Copy link
Contributor

Choose a reason for hiding this comment

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

I really don't like that now we have an error that we have to check that

  1. we have no way of fixing
  2. it's very unlikely that crypto/rand#Read will return an error >.>

But unfortunately I don't have any proposals ...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I often use https://github.com/satori/go.uuid in all my previous projects. Since when I see current uuid package inside vendor already, I don't want to touch it in this PR.

We can ignore it for now and I will create separate issue for changing uuid package.

How do you think @na-- @imiric ?

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think changing to a different uuid package will help us with the error ... the problem is crypto.rand#Read can return an error and all of those use them AFAIK.
My argument is that we don't need crypto/rand at all and the non crypto one always returns a nil error :). But I don't think it's that big of problem ... I just really dislike that we added another error that is

  1. unlikely
  2. we have 0 things we can do, but report it or retry ... maybe
  3. can't test

Copy link
Contributor Author

@cuonglm cuonglm Oct 21, 2019

Choose a reason for hiding this comment

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

@mstoykov not sure what's your point. My point is that satori/go.uuid does not return error when creating new uuid string, so we don't have to check for error.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm, maybe I should change the current uuid package, as I see it is not used anywhere in current code base (not sure why it existed in vendor)

Copy link
Contributor

Choose a reason for hiding this comment

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

Okay ... so apparently that lib you just added has a last version which panics on error and the very next commit (nearly 2 years ago) adds an error to be returned, which is what is shown in the godoc.

I am very strongly against adding another UUID lib ... just for this.

I am also very opposed to panicing. I have no idea when crypto/rand could return an error but still.

For me using crypto/rand for this particular token is an overkill .. if it wasn't for the fact we add an error I would've not cared :) , but that is what is bugging me - the error(or panic with this new lib), not the fact we use crypto for it

Copy link
Contributor Author

@cuonglm cuonglm Oct 21, 2019

Choose a reason for hiding this comment

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

@mstoykov note that even you use math/rand, its rand.Read still returns an error, too. So why bother to use crypto/rand

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@mstoykov Here's what I often do:

func generateSessionToken() string {
	// 16 hex characters
	b := make([]byte, 8)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return hex.EncodeToString(b)
}

Copy link
Contributor

@mstoykov mstoykov Oct 21, 2019

Choose a reason for hiding this comment

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

as per the docs it always return a nil error, so we can ignore it safely knowing it's never going to happen.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@mstoykov good point 👍

@cuonglm cuonglm requested a review from mstoykov October 21, 2019 09:03
stats/cloud/client.go Outdated Show resolved Hide resolved
@cuonglm cuonglm requested a review from na-- October 23, 2019 11:02
stats/cloud/client.go Outdated Show resolved Hide resolved
stats/cloud/client.go Show resolved Hide resolved
@na--
Copy link
Member

na-- commented Oct 23, 2019

As we spoke on the call, I'm completely walking back from my desire of a session-id 😞 We don't currently need it, and it would unnecessarily complicate the distributed execution, so the per-request idempotency key would be sufficient for now.

@cuonglm
Copy link
Contributor Author

cuonglm commented Oct 24, 2019

As we spoke on the call, I'm completely walking back from my desire of a session-id 😞 We don't currently need it, and it would unnecessarily complicate the distributed execution, so the per-request idempotency key would be sufficient for now.

For clarifying, "per-request" means "per-POST-request" or every requests?

@na--
Copy link
Member

na-- commented Oct 24, 2019

As we spoke on the call, I'm completely walking back from my desire of a session-id disappointed We don't currently need it, and it would unnecessarily complicate the distributed execution, so the per-request idempotency key would be sufficient for now.

For clarifying, "per-request" means "per-POST-request" or every requests?

Per non-GET/non-HEAD request, since these should be idempotent by default 😉 And while we currently have only POST requests IIRC, we might have PUT or DELETE requests in the future, where this could also be useful

@mstoykov
Copy link
Contributor

I am still of the opinion that the session token should be set by each and every constructor of the corresponding request not in Client.Do.

@na--
Copy link
Member

na-- commented Oct 24, 2019

@mstoykov, let's not call it session token, since that implies it's persistent for the whole session (which we agreed we're not going to do now, since it would complicate distributed execution and is not needed to fix the particular problem). Rather, let's call the new header Idempotency-Key, borrowing from https://stripe.com/docs/api/idempotent_requests (kudos to @cuonglm for sending me that link).

But yeah, it'd probably be more visible and understandable if we're setting that header value in the request constructor, not in the Do() method...

stats/cloud/api_test.go Outdated Show resolved Hide resolved
stats/cloud/api_test.go Outdated Show resolved Hide resolved
@@ -94,6 +100,10 @@ func (c *Client) Do(req *http.Request, v interface{}) error {
}
}

if shouldAddIdempotencyKey(req) {
Copy link
Member

Choose a reason for hiding this comment

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

Like we mentioned in the PR global comments, this seems better suited for the NewRequest() method

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I against it, as we may want the caller to supply its own Idempotency-Key. Also, I have a patch local here, which will move all request header preparation to here instead of in Client.do, but it will go after this PR merged.

Copy link
Member

Choose a reason for hiding this comment

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

Fair enough, but the user would also be able to supply their own idempotency key even if we handle this in NewRequest(), it would just be done by deliberately overwriting whatever we generated there.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed.

stats/cloud/api_test.go Show resolved Hide resolved
stats/cloud/client.go Outdated Show resolved Hide resolved
@@ -94,6 +100,10 @@ func (c *Client) Do(req *http.Request, v interface{}) error {
}
}

if shouldAddIdempotencyKey(req) {
Copy link
Member

Choose a reason for hiding this comment

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

Fair enough, but the user would also be able to supply their own idempotency key even if we handle this in NewRequest(), it would just be done by deliberately overwriting whatever we generated there.

@cuonglm cuonglm requested a review from na-- October 25, 2019 06:25
stats/cloud/client.go Outdated Show resolved Hide resolved
na--
na-- previously approved these changes Oct 25, 2019
Copy link
Member

@na-- na-- left a comment

Choose a reason for hiding this comment

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

LGTM now 😄 Just please squash it to one or two (timeout increase+the rest) commits. And maybe add a //nolint:gosec in randomStrHex() so that the linter doesn't complain.

When k6 requests to cloud API timeout, k6 will retry the request a
few times. This can be problematic, because the retrying request can
cause user's test start twice.

This PR tries to fix that problem by using an idempotency key for each
request. With this, cloud API can avoid handling duplicated requests.

Fixes #1205
@cuonglm cuonglm requested a review from mstoykov October 25, 2019 06:59
@cuonglm cuonglm changed the title Make cloud API post request idempotent Make cloud API requests idempotent Oct 25, 2019
@cuonglm cuonglm merged commit bbe7794 into master Oct 25, 2019
@cuonglm cuonglm deleted the feature/1205 branch October 25, 2019 09:46
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.

Use a session token for k6 cloud API calls
5 participants