-
Notifications
You must be signed in to change notification settings - Fork 55
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
feat(client): expose a default Requester interface #310
Conversation
8bf7c9a
to
d2d3c89
Compare
d2d3c89
to
b44cdf9
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for your astounding work on this Fred! I left a couple comments and nitpicks. Otherwise, I'm really happy about these changes (:
cdf3cee
to
254d0fb
Compare
I've had a look over this now and I small play with it in a test CLI that implements a custom client. Maybe it's just me, but I find the approach, and some of the naming, hard to understand. The approach: I find the new requester and decoder concepts (and interfaces) indirect and a bit confusing. I know we initially discussed just exporting a It would be helpful to add doc comments on the Why do we need
Why does a decoder take a (cancellation) context? Could it take just the body instead of the entire I don't really understand what the intended use for Naming and other minor points:
|
254d0fb
to
2a3f325
Compare
The patch adds a public Requester interface which will allow derived projects to use the Pebble instantiated client to extend the available commands. The interface is designed to also allow completely replacing the default implementation provided by the Pebble client. The changes made in the patch has been done in a way to produce as small as possible diffs (we keep doSync/doAsyc wrappers). The default interface has been implemented in the client.go file to allow reviewers to easily identify which code was added, and which is unchanged. The following changes are made: 1. The ResultInfo type previously returned by doSync and doAsync private function are removed. Although this is a publicly exposed type, the return value as of today has always been discarded, and the struct is currently empty. 2. ResultInfo has been replaced by RequestResponse. 3. The logs client request now uses the same retry logic on GET failure, as any other GET request. This is previously not possible because the retry logic and response unmarshall code was bundled, not allow raw access to the HTTP body. 4. The CloseIdleConnections() call has been removed as the final Daemon termination step (now in line with Snapd daemon termination). The normal use case for this call is to free idle connections associated with the HTTP client transport instance. This is used in cases where connection reuse cannot occur (concurrent requests, or requests for which the body is not freed immediately) and the transport connection pool builds up idle connections over time (this can also be controlled with idle connection timeout settings). In our case, as the final step before process termination of the server, this is something the garbage collector does in any case just fine, so we really do not need this.
2a3f325
to
b9e1514
Compare
I had a voice discussion with @flotter and wondered why we can't use a much simpler version that passes in an // client.go
type Client { ... }
type Config {
SocketPath string
...
HTTPClient *http.Client // new field (or perhaps http.RoundTripper)
}
func New(config *Config) { } // existing function
// new method on Client type
func (c *Client) Do(ctx context.Context, request *Request, result interface{}) (*Response, error) { } This saves the complexity of the new interface, the awkward |
We do not want to make the interface only satisfy the RoundTripper interface because that alone cannot make Websockets work. We need to expose an interface that ensures currently Client requirements are met.
tl;dr: I'm not happy with this as is. It's unnecessarily complex, and the new interfaces are hard to implement -- I've implemented a custom requester and decoder to try it. It's not an API I'd be proud of, and I think we can solve the problems at hand in a simpler way. Existing approach, with custom Requester and DecoderFuncI find the decoder interface particularly confusing: you create a client with a Draft PR showing my custom requester and decoder and a test program to drive them. When you implement a If we exported When you implement a In addition, because of how the I understand the desire to conceptually separate the Click to expand new API surfacetype DecoderFunc func(ctx context.Context, res *http.Response, opts *RequestOptions, result interface{}) (*RequestResponse, error)
type Requester interface {
Do(ctx context.Context, opts *RequestOptions, result interface{}) (*RequestResponse, error)
SetDecoder(decoder DecoderFunc)
Transport() *http.Transport
}
type RequestOptions struct {
Method string
Path string
Query url.Values
Headers map[string]string
Body io.Reader
Async bool
ReturnBody bool
}
type RequestResponse struct {
StatusCode int
ChangeID string
Body io.ReadCloser
}
func (client *Client) Requester() Requester { ... }
type DefaultRequesterConfig struct {
BaseURL string
Socket string
DisableKeepAlive bool
UserAgent string
}
type DefaultRequester struct {
baseURL url.URL
doer doer
userAgent string
transport *http.Transport
decoder DecoderFunc
}
func NewDefaultRequester(opts *DefaultRequesterConfig) (*DefaultRequester, error) { ... } Still, my draft PR shows my custom requester and custom decoder used to implement I use the custom decoder to decode a pretend new Termus-specific Another oddity: the custom decoder decodes warnings/maintenance fields into fields on It all works, though I did have to add a Overall, I think it's painful to use, with a lot of boilerplate copied from the current implementation in client.go. Let's see if we can simplify. Simpler option 1As mentioned above, I don't think it's useful (or even possible with websockets / The custom decoder above has to decode the entire response, so let's instead provide a "decode hook" which allows setting a custom function to just read or unmarshal the parts it wants (like In option 1 (draft PR), I've taken the existing func (client *Client) Do(ctx context.Context, opts *RequestOptions, result interface{})
(*RequestResponse, error) { .. } For the decode hook, there's this new API: func (client *Client) SetDecodeHook(hook DecodeHookFunc) {
client.decodeHook = hook
}
type DecodeHookFunc func(data []byte, opts *RequestOptions) error You can see how simple a sample implementation is here -- it just decodes the Click to expand sample decode hookfunc (c *MyClient) decodeHook(data []byte, opts *client.RequestOptions) error {
// Demonstrate use of opts: only do custom decode on v1 requests.
if !strings.HasPrefix(opts.Path, "/v1/") {
return nil
}
var frame struct {
ServerVersion string `json:"server-version"`
}
err := json.Unmarshal(data, &frame)
if err != nil {
return err
}
if frame.ServerVersion != "" {
c.ServerVersion = frame.ServerVersion
}
return nil
} I realize it's slightly less efficient, as it will have to unmarshal a second time (the client unmarshals as well). But 1) it's a small price to pay for how much simpler this approach is, and 2) I think it's likely decode hooks won't be used often. I've also solved the websocket issue by adding a Simpler option 2Option 2 (draft PR) is similar to the above, but I've broken I don't like how In addition, they should really take and return different parameters in each mode:
Separating them out is clearer, and means the parameters and return values are specific to the operation (making invalid combinations impossible at compile time). One other tweak here: because Here's the option 2 methods: func (client *Client) DoSync(ctx context.Context, method, path string, opts *RequestOptions, result interface{})
error { ... }
func (client *Client) DoAsync(ctx context.Context, method, path string, opts *RequestOptions)
(changeID string, err error) { ... }
func (client *Client) DoRaw(ctx context.Context, method, path string, opts *RequestOptions)
(io.ReadCloser, error) { ... } Compare a simple do call with option 1 -- not bad, but not great: opts := &client.RequestOptions{ // Async: false is implicit
Method: "GET",
Path: "/v1/services",
}
_, err := c.pebble.Do(ctx, opts, &result) With option 2 -- simpler and more type safe: err := c.pebble.DoSync(ctx, "GET", "/v1/services", nil, &result) Again, option 2 adds SummaryI believe the suggested options solve the problem of adding Termus-specific functionality and custom decoding. They do so with much less API surface, less code in the client.go implementation, and they're significantly simpler to use when you're importing Pebble or writing a custom decoder. Of the two option, I prefer option 2 for the reasons stated there. It does add 3 new exported API methods instead of 1, but they're all clear and the parameters and result types are consistent with the operation. Later, if we really do need to replace the entire request handling with something non-HTTP, we can add a way to inject a Perhaps we can have a discussion about this to avoid going round in further circles. |
Thanks for taking the time to describe these alternatives in detail, Ben. Unfortunately, they seem to ignore the conversation we had with the team on Tuesday where I detailed why we're trying to avoid having methods on the With that said, I've worked with Fred today to simplify the current proposal further, and drop from it concerns that we do not need to solve today, such as how to implement an external requester. That means the Requester interface is exposed, but can change because none of our own APIs publish a way for third-party requesters to be used. With this change, we take away the needs of having an exposed decoder, which I will document below for posterity regardless. As explained on Tuesday, the previously proposed decoder interface allows third-party code that leverages Pebble's foundation to use the Requester interface, while still allowing the Client itself to capture auxiliar metadata that is transferred to the client as part of request response. Warnings are a good example: any sync or async request may return warnings which are unrelated to the request at hand. As the third-party logic performs a Do request, we don't want it to be the responsible for decoding, or even knowing about, all such side metadatac carried by the request. That's why the interface is less trivial than it could be otherwise. Either way, we'll simplify it further and keep some of these concerns private while we can do that. |
Thanks, @niemeyer and @flotter. I'm much happier starting simple and expanding later as we have concrete use cases for a decoder.
Sorry about that. I tried to address that with: "I understand the desire to conceptually separate the Requester, Client, and decoder. However, it creates a lot of complexity..." But I got a bit carried away with my alternative proposals and sort of forgot where we ended up with our discussion in the process. I'm pretty happy with your simplified Separating out |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, Fred! Getting close!
Just to be clear, this was indeed the actual agreement from last week. The interface itself is not moot because it reserves the ability to have different requesters in the future, but we do intend to make the concrete implementation private for now. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is looking really good now, thanks. A couple of very nit comments, and one note about (I think) missing setting baseURL
in defaultRequester
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks excellent now, thanks!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good, thanks all!
The patch adds a public Requester interface which allow derived projects to use the Pebble instantiated client and extend the available commands. The extended client commands will use
client.Requester().Do
for HTTP requests.Derived project (imports Pebble):
The changes made in the patch has been done in a way to produce as small
as possible diffs (we keep doSync/doAsyc wrappers). The default interface
has been implemented in the client.go file to allow reviewers to easily
identify which code was added, and which is unchanged.
The following changes are made:
The ResultInfo type previously returned by doSync and doAsync private
function are removed. Although this is a publicly exposed type, the return
value as of today has always been discarded, and the struct is currently
empty.
ResultInfo has been replaced by RequestResponse.
The client Hijack mechanism which allowed the doer to be replaced has been
removed. This was originally added in snapd for an arguably slightly obscure use
case. We now have a new Requester interface, so any future need
for this will instead be built in top of the Requester interface.
The logs client request now uses the same retry logic on GET failure,
as any other GET request. This is previously not possible because the
retry logic and response unmarshall code was bundled, not allow raw access
to the HTTP body.