Search code examples
c#task-parallel-libraryrestsharpcancellationdropnet

How to implement CancellationToken support in DropNet?


I want to asynchronously access DropBox API in a MonoTouch app.
I thought it would be convenient to use DropNet which itself relies on RestSharp.

Both libraries work well but DropNet overloads that return Tasks don't give you a way to associate requests with cancellation tokens.

This is how their implementation looks:

public Task<IRestResponse> GetThumbnailTask(string path, ThumbnailSize size)
{
    if (!path.StartsWith("/")) path = "/" + path;
    var request = _requestHelper.CreateThumbnailRequest(path, size, Root);
    return ExecuteTask(ApiType.Content, request, cancel);
}

ExecuteTask implementation is based on TaskCompletionSource and was originally written by Laurent Kempé:

public static class RestClientExtensions
{
    public static Task<TResult> ExecuteTask<TResult>(
        this IRestClient client, IRestRequest request
        ) where TResult : new()
    {
        var tcs = new TaskCompletionSource<TResult>();

        WaitCallback asyncWork = _ => {
            try {
                client.ExecuteAsync<TResult>(request,
                    (response, asynchandle) => {
                        if (response.StatusCode != HttpStatusCode.OK) {
                            tcs.SetException(new DropboxException(response));
                        } else {
                            tcs.SetResult(response.Data);
                        }
                    }
                );
            } catch (Exception exc) {
                    tcs.SetException(exc);
            }
        };

        return ExecuteTask(asyncWork, tcs);
    }


    public static Task<IRestResponse> ExecuteTask(
        this IRestClient client, IRestRequest request
        )
    {
        var tcs = new TaskCompletionSource<IRestResponse>();

        WaitCallback asyncWork = _ => {
            try {
                client.ExecuteAsync(request,
                    (response, asynchandle) => {
                        if (response.StatusCode != HttpStatusCode.OK) {
                            tcs.SetException(new DropboxException(response));
                        } else {
                            tcs.SetResult(response);
                        }
                    }
                );
            } catch (Exception exc) {
                    tcs.SetException(exc);
            }
        };

        return ExecuteTask(asyncWork, tcs);
   }

    private static Task<TResult> ExecuteTask<TResult>(
        WaitCallback asyncWork, TaskCompletionSource<TResult> tcs
        )
    {
        ThreadPool.QueueUserWorkItem(asyncWork);
        return tcs.Task;
    }
}

How do I change or extend this code to support cancellation with CancellationToken?
I'd like to call it like this:

var task = dropbox.GetThumbnailTask(
    "/test.jpg", ThumbnailSize.ExtraLarge2, _token
);

Solution

  • Because CancellationToken is a value type, we can add it as an optional parameter to the APIs with default value and avoid null checks, which is sweet.

    public Task<IRestResponse> GetThumbnailTask(
        string path, ThumbnailSize size, CancellationToken cancel = default(CancellationToken)
    ) {
        if (!path.StartsWith("/")) path = "/" + path;
        var request = _requestHelper.CreateThumbnailRequest(path, size, Root);
        return ExecuteTask(ApiType.Content, request, cancel);
    }
    

    Now, RestSharp ExecuteAsync method returns RestRequestAsyncHandle that encapsulates underlying HttpWebRequest, along with Abort method. This is how we cancel things.

    public static Task<TResult> ExecuteTask<TResult>(
        this IRestClient client, IRestRequest request, CancellationToken cancel = default(CancellationToken)
        ) where TResult : new()
    {
        var tcs = new TaskCompletionSource<TResult>();
        try {
            var async = client.ExecuteAsync<TResult>(request, (response, _) => {
                if (cancel.IsCancellationRequested || response == null)
                    return;
    
                if (response.StatusCode != HttpStatusCode.OK) {
                    tcs.TrySetException(new DropboxException(response));
                } else {
                    tcs.TrySetResult(response.Data);
                }
            });
    
            cancel.Register(() => {
                async.Abort();
                tcs.TrySetCanceled();
            });
        } catch (Exception ex) {
            tcs.TrySetException(ex);
        }
    
        return tcs.Task;
    }
    
    public static Task<IRestResponse> ExecuteTask(this IRestClient client, IRestRequest request, CancellationToken cancel = default(CancellationToken))
    {
        var tcs = new TaskCompletionSource<IRestResponse>();
        try {
            var async = client.ExecuteAsync<IRestResponse>(request, (response, _) => {
                if (cancel.IsCancellationRequested || response == null)
                    return;
    
                if (response.StatusCode != HttpStatusCode.OK) {
                    tcs.TrySetException(new DropboxException(response));
                } else {
                    tcs.TrySetResult(response);
                }
            });
    
            cancel.Register(() => {
                async.Abort();
                tcs.TrySetCanceled();
            });
        } catch (Exception ex) {
            tcs.TrySetException(ex);
        }
    
        return tcs.Task;
    }
    

    Finally, Lauren's implementation puts requests in thread pool but I don't see a reason to do so—ExecuteAsync is asynchronous itself. So I don't do that.

    And that's it for cancelling DropNet operations.

    I also made a few tweaks which may be useful to you.

    Because I had no way of scheduling DropBox Tasks without resorting to wrapping ExecuteTask calls into another Tasks, I decided to find an optimal concurrency level for requests, which turned out to be 4 for me, and set it explicitly:

    static readonly Uri DropboxContentHost = new Uri("https://api-content.dropbox.com");
    
    static DropboxService()
    {
        var point = ServicePointManager.FindServicePoint(DropboxContentHost);
        point.ConnectionLimit = 4;
    }
    

    I was also content with letting unhandled task exceptions rot in hell, so that's what I did:

    TaskScheduler.UnobservedTaskException += (sender, e) => {
        e.SetObserved();
    };
    

    One last observation is you shouldn't call Start() on a task returned by DropNet because the task starts right away. If you don't like that, you'll need to wrap ExecuteTask in yet another “real” task not backed by TaskCompletionSource.