Skip to content

Idempotent functions cannot return both data & errors #157

@akutz

Description

@akutz

Long Story Short

We cannot return both a value and an error for idempotent calls. I propose we use the gRPC error details as was discussed previously. I still want to know if an idempotent result was idempotent. Short of that, we have to drop the errors completely.

This is a P0 in my opinion. We need to nail down the expected behavior prior to v0.1.0.

Long Story Long

As @cpuguy83 has pointed out, returning a non-nil result with a non-nil error is not idiomatic Go. Turns out, Go gRPC doesn't even give you a choice. While the gRPC spec appears to support the idea of returning both data and errors, the Go implementation of gRPC does not. Using CreateVolume as an example, this problem occurs in two places:

The Generated Client Code (link)

func (c *controllerClient) CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error) {
	out := new(CreateVolumeResponse)
	err := grpc.Invoke(ctx, "/csi.Controller/CreateVolume", in, out, c.cc, opts...)
	if err != nil {
		return nil, err
	}
	return out, nil
}

Note that if the error is not nil, a nil response is always returned. The thing is, I thought I could side-step this by creating my own client:

type controllerClient struct {
	csi.ControllerClient
	cc *grpc.ClientConn
}

// NewControllerClient returns a new client for the CSI controller service.
func NewControllerClient(cc *grpc.ClientConn) csi.ControllerClient {
	return &controllerClient{
		ControllerClient: csi.NewControllerClient(cc),
		cc:               cc,
	}
}

func (c *controllerClient) CreateVolume(
	ctx context.Context,
	in *csi.CreateVolumeRequest,
	opts ...grpc.CallOption) (*csi.CreateVolumeResponse, error) {

	out := new(csi.CreateVolumeResponse)
	return out, grpc.Invoke(
		ctx, "/csi.Controller/CreateVolume", in, out, c.cc, opts...)
}

This is the weird part -- it still doesn't work. So I did some more digging.

The gRPC Package Code (link)

		err = sendRequest(ctx, cc.dopts, cc.dopts.cp, c, callHdr, stream, t, args, topts)
		if err != nil {
			if done != nil {
				done(balancer.DoneInfo{
					Err:           err,
					BytesSent:     true,
					BytesReceived: stream.BytesReceived(),
				})
			}
			// Retry a non-failfast RPC when
			// i) the server started to drain before this RPC was initiated.
			// ii) the server refused the stream.
			if !c.failFast && stream.Unprocessed() {
				// In this case, the server did not receive the data, but we still
				// created wire traffic, so we should not retry indefinitely.
				if firstAyttempt {
					// TODO: Add a field to header for grpc-transparent-retry-attempts
					firstAttempt = false
					continue
				}
				// Otherwise, give up and return an error anyway.
			}
			return toRPCErr(err)
		}
		err = recvResponse(ctx, cc.dopts, t, c, stream, reply)

If the invoker receives an error then the response isn't even received and marshalled.

cc @saad-ali @jieyu @jdef @julian-hj

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions