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

Error Handling #275

Merged
merged 11 commits into from
Jun 23, 2018
8 changes: 7 additions & 1 deletion TMDbLib/Client/TMDbClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ public TMDbConfig Config

public bool HasConfig { get; private set; }

public bool ThrowExceptionsOnNotFound
{
get { return _client.ThrowExceptionsOnNotFound; }
set { _client.ThrowExceptionsOnNotFound = value; }
}

/// <summary>
/// The maximum number of times a call to TMDb will be retried
/// </summary>
Expand Down Expand Up @@ -101,7 +107,7 @@ public int MaxRetryCount
/// <remarks>
/// The Web Proxy is optional. If set, every request will be sent through it.
/// Use the constructor for setting it.
///
///
/// For convenience, this library also offers a <see cref="IWebProxy"/> implementation.
/// Check <see cref="Utilities.TMDbAPIProxy"/> for more information.
/// </remarks>
Expand Down
7 changes: 3 additions & 4 deletions TMDbLib/Client/TMDbClientMovies.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ public partial class TMDbClient
RestResponse<Movie> response = await req.ExecuteGet<Movie>(cancellationToken).ConfigureAwait(false);

// No data to patch up so return
if (response == null) return null;
if (response == null || !response.IsValid) return null;
Copy link
Collaborator

Choose a reason for hiding this comment

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

We shouldn't check for IsValid in some places.

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 is also related to trying to return null.. Response isn't null, as it's a wrapper containing the null value.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Another thing to fix then is that if ExecuteGet always returns non-null - we should never check for null.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, that's probably a good idea.


Movie item = await response.GetDataObject().ConfigureAwait(false);

Expand Down Expand Up @@ -172,7 +172,7 @@ public partial class TMDbClient
return await GetMovieMethod<KeywordsContainer>(movieId, MovieMethods.Keywords, cancellationToken: cancellationToken).ConfigureAwait(false);
}

public async Task<Movie> GetMovieLatestAsync( CancellationToken cancellationToken = default(CancellationToken))
public async Task<Movie> GetMovieLatestAsync(CancellationToken cancellationToken = default(CancellationToken))
{
RestRequest req = _client.Create("movie/latest");
RestResponse<Movie> resp = await req.ExecuteGet<Movie>(cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -382,5 +382,4 @@ private async Task<T> GetMovieMethod<T>(int movieId, MovieMethods movieMethod, s
return item.StatusCode == 1 || item.StatusCode == 12;
}
}
}

}
12 changes: 12 additions & 0 deletions TMDbLib/Objects/Exceptions/APIException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace TMDbLib.Objects.Exceptions
{
public class APIException : TMDbException
{
public TMDbStatusMessage StatusMessage { get; }

public APIException(string message, TMDbStatusMessage statusMessage) : base(message)
{
StatusMessage = statusMessage;
}
}
}
15 changes: 15 additions & 0 deletions TMDbLib/Objects/Exceptions/GeneralHttpException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Net;

namespace TMDbLib.Objects.Exceptions
{
public class GeneralHttpException : TMDbException
{
public HttpStatusCode HttpStatusCode { get; }

public GeneralHttpException(HttpStatusCode httpStatusCode)
: base("TMDb returned an unexpected HTTP error")
{
HttpStatusCode = httpStatusCode;
}
}
}
10 changes: 10 additions & 0 deletions TMDbLib/Objects/Exceptions/NotFoundException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace TMDbLib.Objects.Exceptions
{
public class NotFoundException : APIException
{
public NotFoundException(TMDbStatusMessage statusMessage)
: base("The requested item was not found", statusMessage)
{
}
}
}
6 changes: 3 additions & 3 deletions TMDbLib/Objects/Exceptions/RequestLimitExceededException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

namespace TMDbLib.Objects.Exceptions
{
public class RequestLimitExceededException : Exception
public class RequestLimitExceededException : APIException
{
public DateTimeOffset? RetryOn { get; }

public TimeSpan? RetryAfter { get; }

internal RequestLimitExceededException(DateTimeOffset? retryOn, TimeSpan? retryAfter)
: base("You have exceeded the maximum number of request allowed by TMDb please try again later")
internal RequestLimitExceededException(TMDbStatusMessage statusMessage, DateTimeOffset? retryOn, TimeSpan? retryAfter)
: base("You have exceeded the maximum number of request allowed by TMDb please try again later", statusMessage)
{
RetryOn = retryOn;
RetryAfter = retryAfter;
Expand Down
12 changes: 12 additions & 0 deletions TMDbLib/Objects/Exceptions/TMDbException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System;

namespace TMDbLib.Objects.Exceptions
{
public class TMDbException : Exception
{
public TMDbException(string message)
: base(message)
{
}
}
}
13 changes: 13 additions & 0 deletions TMDbLib/Objects/Exceptions/TMDbStatusMessage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Newtonsoft.Json;

namespace TMDbLib.Objects.Exceptions
{
public class TMDbStatusMessage
{
[JsonProperty("status_code")]
public int StatusCode { get; set; }

[JsonProperty("status_message")]
public string StatusMessage { get; set; }
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is the object from TMDb?

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
Collaborator

Choose a reason for hiding this comment

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

Good.

}
2 changes: 2 additions & 0 deletions TMDbLib/Rest/RestClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ public int MaxRetryCount
}
}

public bool ThrowExceptionsOnNotFound { get; set; }

internal JsonSerializer Serializer { get; }

public void AddDefaultQueryString(string key, string value)
Expand Down
50 changes: 30 additions & 20 deletions TMDbLib/Rest/RestRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ public RestRequest AddParameter(string key, string value, ParameterType type = P
{
case ParameterType.QueryString:
return AddQueryString(key, value);

case ParameterType.UrlSegment:
return AddUrlSegment(key, value);

default:
throw new ArgumentOutOfRangeException(nameof(type), type, null);
}
Expand Down Expand Up @@ -84,63 +86,45 @@ private void AppendQueryString(StringBuilder sb, KeyValuePair<string, string> va
AppendQueryString(sb, value.Key, value.Value);
}

private void CheckResponse(HttpResponseMessage response)
{
if (response.StatusCode == HttpStatusCode.Unauthorized)
throw new UnauthorizedAccessException("Call to TMDb returned unauthorized. Most likely the provided API key is invalid.");
}

public async Task<RestResponse> ExecuteDelete(CancellationToken cancellationToken)
{
HttpResponseMessage resp = await SendInternal(HttpMethod.Delete, cancellationToken).ConfigureAwait(false);

CheckResponse(resp);

return new RestResponse(resp);
}

public async Task<RestResponse<T>> ExecuteDelete<T>(CancellationToken cancellationToken)
{
HttpResponseMessage resp = await SendInternal(HttpMethod.Delete, cancellationToken).ConfigureAwait(false);

CheckResponse(resp);

return new RestResponse<T>(resp, _client);
}

public async Task<RestResponse> ExecuteGet(CancellationToken cancellationToken)
{
HttpResponseMessage resp = await SendInternal(HttpMethod.Get, cancellationToken).ConfigureAwait(false);

CheckResponse(resp);

return new RestResponse(resp);
}

public async Task<RestResponse<T>> ExecuteGet<T>(CancellationToken cancellationToken)
{
HttpResponseMessage resp = await SendInternal(HttpMethod.Get, cancellationToken).ConfigureAwait(false);

CheckResponse(resp);

return new RestResponse<T>(resp, _client);
}

public async Task<RestResponse> ExecutePost(CancellationToken cancellationToken)
{
HttpResponseMessage resp = await SendInternal(HttpMethod.Post, cancellationToken).ConfigureAwait(false);

CheckResponse(resp);

return new RestResponse(resp);
}

public async Task<RestResponse<T>> ExecutePost<T>(CancellationToken cancellationToken)
{
HttpResponseMessage resp = await SendInternal(HttpMethod.Post, cancellationToken).ConfigureAwait(false);

CheckResponse(resp);

return new RestResponse<T>(resp, _client);
}

Expand Down Expand Up @@ -201,13 +185,21 @@ private async Task<HttpResponseMessage> SendInternal(HttpMethod method, Cancella

Debug.Assert(timesToTry >= 1);

TMDbStatusMessage statusMessage = null;

do
{
using (HttpRequestMessage req = PrepRequest(method))
{
HttpResponseMessage resp =
await _client.HttpClient.SendAsync(req, cancellationToken).ConfigureAwait(false);

if (!resp.IsSuccessStatusCode)
{
statusMessage =
JsonConvert.DeserializeObject<TMDbStatusMessage>(await resp.Content.ReadAsStringAsync());
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will there always be a Status message object in erronous responses?

Copy link
Collaborator

@LordMike LordMike Jun 20, 2018

Choose a reason for hiding this comment

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

Aha. Just read #270 - we've seen HTML before.

Perhaps we should also check the Content-Type on the response. If it is not JSON (application/json), it's an error, and we should throw the GeneralException -- regardless if it's a known response, such as 404.

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, not necesarily. An issue was reported were the API returned HTML instead of JSON. Not sure how to handle it exactly.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Check for the Content-Type. I imagine that after checking for 429 responses, we check if the Content Type is json. If it isn't, then it's an error no matter what the status code is.

Copy link
Collaborator

Choose a reason for hiding this comment

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

You can also move the parsing down to the other if..

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Want to throw a specific exception for that?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmm. For response other JSON - no. It's an error, yes, but it should feed back in to the rest of the loop.

We just should not even attempt to deserialize a status message (or an object) - if the content type is not json.

if (resp.StatusCode == (HttpStatusCode)429)
{
// The previous result was a ratelimit, read the Retry-After header and wait the allotted time
Expand All @@ -227,12 +219,30 @@ private async Task<HttpResponseMessage> SendInternal(HttpMethod method, Cancella
return resp;

if (!resp.IsSuccessStatusCode)
return resp;
{
switch (resp.StatusCode)
{
case HttpStatusCode.Unauthorized:
throw new UnauthorizedAccessException("Call to TMDb returned unauthorized. Most likely the provided API key is invalid.");

case HttpStatusCode.NotFound:
if (_client.ThrowExceptionsOnNotFound)
{
throw new NotFoundException(statusMessage);
}
else
{
return null;
}
}
throw new GeneralHttpException(resp.StatusCode);
//return resp;
}
}
} while (timesToTry-- > 0);

// We never reached a success
throw new RequestLimitExceededException(retryHeader?.Date, retryHeader?.Delta);
throw new RequestLimitExceededException(statusMessage, retryHeader?.Date, retryHeader?.Delta);
}

public RestRequest SetBody(object obj)
Expand Down
11 changes: 10 additions & 1 deletion TMDbLib/Rest/RestResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public RestResponse(HttpResponseMessage response)
Response = response;
}

public bool IsValid { get { return Response != null; } }

public HttpStatusCode StatusCode => Response.StatusCode;

public async Task<Stream> GetContent()
Expand Down Expand Up @@ -53,7 +55,14 @@ public static implicit operator T(RestResponse<T> response)
{
try
{
return response.GetDataObject().Result;
if (response.IsValid)
{
return response.GetDataObject().Result;
}
else
{
return default(T);
Copy link
Collaborator

Choose a reason for hiding this comment

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

In this case, I'd rather fail. IIRC, the RestResponse is never exposed -- so we are in complete control of calls.

For this case, it should be "if (!Valid) throw new ..".

Copy link
Contributor Author

Choose a reason for hiding this comment

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

See my comment in the PR thread about this line.

}
}
catch (AggregateException ex)
{
Expand Down
8 changes: 8 additions & 0 deletions TMDbLibTests/ClientCreditTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ public void TestGetCreditTv()
Assert.Equal("", result.Media.Character);
}

[Fact]
public void TestMissingCredit()
{
Credit result = Config.Client.GetCreditsAsync("9999999999").Result;
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should make a single test that tests the exception-throwing part. Testing each "area" of TMDb once for not-found is probably a good idea. That API hasn't really shined in concistency :)


Assert.Null(result);
}

[Fact]
public void TestGetCreditEpisode()
{
Expand Down
13 changes: 11 additions & 2 deletions TMDbLibTests/ClientMovieTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,6 @@ public void TestMoviesGetMovieCasts()
Assert.Equal("Music", crew.Job);
Assert.Equal("Marco Beltrami", crew.Name);
Assert.True(TestImagesHelpers.TestImagePath(crew.ProfilePath), "crew.ProfilePath was not a valid image path, was: " + crew.ProfilePath);

}

[Fact]
Expand Down Expand Up @@ -437,6 +436,16 @@ public void TestMoviesGetMovieChanges()
}
}

[Fact]
public void TestMoviesMissing()
{
Movie movie1 = Config.Client.GetMovieAsync(999999999).Result;
Assert.Null(movie1);

Movie movie2 = Config.Client.GetMovieAsync(230).Result;
Assert.Null(movie2);
}

[Fact]
public void TestMoviesImages()
{
Expand Down Expand Up @@ -836,4 +845,4 @@ public void TestMoviesExtrasAccountState()
Assert.True(Math.Abs(movie.AccountStates.Rating.Value - 5) < double.Epsilon);
}
}
}
}