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

Introduce AbuseException #1528

Merged
merged 35 commits into from
Jan 11, 2017
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
b3c8b64
Merge remote-tracking branch 'refs/remotes/octokit/master'
SeanKilleen Jan 8, 2017
b3e85f2
Add some tests to be completed
SeanKilleen Jan 9, 2017
56f853e
Actually fail the tests
SeanKilleen Jan 9, 2017
f9bbceb
Create AbuseException class
SeanKilleen Jan 9, 2017
8a915b2
Actually add AbuseException to csproj
SeanKilleen Jan 9, 2017
8095d10
Update test file
SeanKilleen Jan 9, 2017
4283923
Ran .\build FixProjects
SeanKilleen Jan 9, 2017
abf73a4
Test updates
SeanKilleen Jan 9, 2017
22524f9
Default message update for AbuseException
SeanKilleen Jan 9, 2017
417133f
Separate the exception creation logic
SeanKilleen Jan 9, 2017
496ee8b
Remove message assertion -- doesn't matter here
SeanKilleen Jan 9, 2017
a5f3f90
Additional test for abuse message
SeanKilleen Jan 9, 2017
048eca7
Remove unnecessary variable assignment
SeanKilleen Jan 9, 2017
e4f7f90
Failing test for unsafe request
SeanKilleen Jan 9, 2017
bfedee2
Attempt to fix test
SeanKilleen Jan 9, 2017
51b0e0d
Remove test that will always fail due to another issue
SeanKilleen Jan 9, 2017
16d8975
New tests (some failing)
SeanKilleen Jan 9, 2017
e1eaac1
Passing tests are, like, better than failing tests.
SeanKilleen Jan 9, 2017
bba1644
Last passing test
SeanKilleen Jan 9, 2017
a0c5747
Cleanup
SeanKilleen Jan 9, 2017
d131b37
Add test for zero value and fix code
SeanKilleen Jan 9, 2017
88f63ad
cleanup
SeanKilleen Jan 9, 2017
01d203a
Mark ParseRetryAfterSeconds as static
SeanKilleen Jan 9, 2017
2acb69e
Add GetObjectData override to AbuseException
SeanKilleen Jan 9, 2017
5edeac4
Add back failing test & skip it
SeanKilleen Jan 9, 2017
ba8fbfe
Change to nullable int with null default
SeanKilleen Jan 9, 2017
47126c9
Fix tests around nullable default
SeanKilleen Jan 9, 2017
04b9475
Merge remote-tracking branch 'refs/remotes/octokit/master' into Add-A…
SeanKilleen Jan 10, 2017
ffb7d6a
whitespace fixes
SeanKilleen Jan 10, 2017
9010f0d
Compact the logic; tests still pass
SeanKilleen Jan 10, 2017
507d2cb
Invert the if statements for compactness / clarity
SeanKilleen Jan 10, 2017
a9b9437
Test subclasses & reformatting
SeanKilleen Jan 10, 2017
fa7ffc7
Test name changes
SeanKilleen Jan 10, 2017
4076e08
Whitespace fix
SeanKilleen Jan 10, 2017
6927842
Remove redundant line
SeanKilleen Jan 10, 2017
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions Octokit.Tests/Exceptions/AbuseExceptionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
using System.Collections.Generic;
using System.Net;
using Octokit.Internal;
using Xunit;

namespace Octokit.Tests.Exceptions
{
public class AbuseExceptionTests
Copy link
Contributor

@ryangribble ryangribble Jan 9, 2017

Choose a reason for hiding this comment

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

The style/verbage used in the naming of the test fixtures isn't too consistent with (most) of the other octokit tests (or more precisely, the direction/consistency we are meandering towards)

We normally word the test fixture naming with sub classes for the functions eg TheConstructor TheParseRetryHeaderMethod etc, and then the name of the [Fixture] test functions is like HandlesEmptyHeader HandlesNullHeader CorrectlyParsesValue and so on.

So that way we end up with common english sounding

AbuseExceptionTests=>TheConstructor=>CorrectlyDeterminesRetryAfterValue

and so on

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@ryangribble makes sense. I'll reformat. Sorry, should've paid more attention to the existing conventions. They tend to be my preference anyway; I think I was likely being hasty.

Copy link
Contributor

Choose a reason for hiding this comment

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

should've paid more attention to the existing conventions.

We have a fairly large code base and some of it has not needed to be touched for quite a while, so it's certainly not all consistent! As we go forward (and as we work with new contributors) we are taking a greater focus on ensuring consistency at least in anything new we implement, so thanks for jumping through these hoops with me 👍

{
[Fact]
public void PostsAbuseMessageFromApi()
{

const string responseBody = "{\"message\":\"You have triggered an abuse detection mechanism. Please wait a few minutes before you try again.\"," +
"\"documentation_url\":\"https://developer.github.com/v3/#abuse-rate-limits\"}";

var response = new Response(
HttpStatusCode.Forbidden,
responseBody,
new Dictionary<string, string>(),
"application/json");
var abuseException = new AbuseException(response);

Assert.Equal("You have triggered an abuse detection mechanism. Please wait a few minutes before you try again.", abuseException.ApiError.Message);
}

[Fact]
public void HasDefaultMessage()
{
var response = new Response(HttpStatusCode.Forbidden, null, new Dictionary<string, string>(), "application/json");
var abuseException = new AbuseException(response);

Assert.Equal("Request Forbidden - Abuse Detection", abuseException.Message);
}

public class RetryAfterHeaderHandling
{
[Fact]
public void WithRetryAfterHeader_PopulatesRetryAfterSeconds()
{
var headerDictionary = new Dictionary<string, string>
{
{ "Retry-After", "30" }
};

var response = new Response(HttpStatusCode.Forbidden, null, headerDictionary, "application/json");
var abuseException = new AbuseException(response);

Assert.Equal(30, abuseException.RetryAfterSeconds);
}

[Fact]
public void NoRetryAfterHeader_RetryAfterSecondsIsSetToTheDefaultOfNull()
{
var headerDictionary = new Dictionary<string, string>();

var response = new Response(HttpStatusCode.Forbidden, null, headerDictionary, "application/json");
var abuseException = new AbuseException(response);

Assert.False(abuseException.RetryAfterSeconds.HasValue);
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void EmptyHeaderValue_RetryAfterSecondsDefaultsToNull(string emptyValueToTry)
{
var headerDictionary = new Dictionary<string, string>
{
{ "Retry-After", emptyValueToTry }
};

var response = new Response(HttpStatusCode.Forbidden, null, headerDictionary, "application/json");
var abuseException = new AbuseException(response);

Assert.False(abuseException.RetryAfterSeconds.HasValue);
}

[Fact]
public void NonParseableIntHeaderValue_RetryAfterSecondsDefaultsToNull()
{
var headerDictionary = new Dictionary<string, string>
{
{ "Retry-After", "ABC" }
};

var response = new Response(HttpStatusCode.Forbidden, null, headerDictionary, "application/json");
var abuseException = new AbuseException(response);

Assert.False(abuseException.RetryAfterSeconds.HasValue);
}

[Fact]
public void NegativeHeaderValue_RetryAfterSecondsDefaultsToNull()
{
var headerDictionary = new Dictionary<string, string>
{
{ "Retry-After", "-123" }
};

var response = new Response(HttpStatusCode.Forbidden, null, headerDictionary, "application/json");
var abuseException = new AbuseException(response);

Assert.False(abuseException.RetryAfterSeconds.HasValue);
}

[Fact]
public void ZeroHeaderValue_RetryAfterSecondsIsZero()
{
var headerDictionary = new Dictionary<string, string>
{
{ "Retry-After", "0" }
};

var response = new Response(HttpStatusCode.Forbidden, null, headerDictionary, "application/json");
var abuseException = new AbuseException(response);

Assert.Equal(0, abuseException.RetryAfterSeconds);
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Extra whitespace line

}
}
}
102 changes: 102 additions & 0 deletions Octokit.Tests/Http/ConnectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,108 @@ public async Task ThrowsForbiddenExceptionForUnknownForbiddenResponse()

Assert.Equal("YOU SHALL NOT PASS!", exception.Message);
}

[Fact]
public async Task ThrowsAbuseExceptionForResponseWithAbuseDocumentationLink()
{
var messageText = "blahblahblah this does not matter because we are testing the URL";

var httpClient = Substitute.For<IHttpClient>();
IResponse response = new Response(
HttpStatusCode.Forbidden,
"{\"message\":\"" + messageText + "\"," +
"\"documentation_url\":\"https://developer.github.com/v3/#abuse-rate-limits\"}",
new Dictionary<string, string>(),
"application/json");
httpClient.Send(Args.Request, Args.CancellationToken).Returns(Task.FromResult(response));
var connection = new Connection(new ProductHeaderValue("OctokitTests"),
_exampleUri,
Substitute.For<ICredentialStore>(),
httpClient,
Substitute.For<IJsonSerializer>());

await Assert.ThrowsAsync<AbuseException>(
() => connection.GetResponse<string>(new Uri("endpoint", UriKind.Relative)));
}

[Fact]
public async Task ThrowsAbuseExceptionForResponseWithAbuseDescription()
{
var messageText = "You have triggered an abuse detection mechanism. Please wait a few minutes before you try again.";

var httpClient = Substitute.For<IHttpClient>();
IResponse response = new Response(
HttpStatusCode.Forbidden,
"{\"message\":\"" + messageText + "\"," +
"\"documentation_url\":\"https://ThisURLDoesNotMatter.com\"}",
new Dictionary<string, string>(),
"application/json");
httpClient.Send(Args.Request, Args.CancellationToken).Returns(Task.FromResult(response));
var connection = new Connection(new ProductHeaderValue("OctokitTests"),
_exampleUri,
Substitute.For<ICredentialStore>(),
httpClient,
Substitute.For<IJsonSerializer>());

await Assert.ThrowsAsync<AbuseException>(
() => connection.GetResponse<string>(new Uri("endpoint", UriKind.Relative)));
}


[Fact]
public async Task AbuseExceptionContainsTheRetryAfterHeaderAmount()
{
var messageText = "You have triggered an abuse detection mechanism. Please wait a few minutes before you try again.";

var httpClient = Substitute.For<IHttpClient>();
var headerDictionary = new Dictionary<string, string>
{
{ "Retry-After", "45" }
};

IResponse response = new Response(
HttpStatusCode.Forbidden,
"{\"message\":\"" + messageText + "\"," +
"\"documentation_url\":\"https://ThisURLDoesNotMatter.com\"}",
headerDictionary,
"application/json");
httpClient.Send(Args.Request, Args.CancellationToken).Returns(Task.FromResult(response));
var connection = new Connection(new ProductHeaderValue("OctokitTests"),
_exampleUri,
Substitute.For<ICredentialStore>(),
httpClient,
Substitute.For<IJsonSerializer>());

var exception = await Assert.ThrowsAsync<AbuseException>(
() => connection.GetResponse<string>(new Uri("endpoint", UriKind.Relative)));

Assert.Equal(45, exception.RetryAfterSeconds);
}

[Fact(Skip = "Fails due to https://github.com/octokit/octokit.net/issues/1529. The message is empty but the default message isn't displayed. However, this isn't due to the introduction of AbuseException, but rather something else.")]
public async Task ThrowsAbuseExceptionWithDefaultMessageForUnsafeAbuseResponse()
{
string messageText = string.Empty;

var httpClient = Substitute.For<IHttpClient>();
IResponse response = new Response(
HttpStatusCode.Forbidden,
"{\"message\":\"" + messageText + "\"," +
"\"documentation_url\":\"https://developer.github.com/v3/#abuse-rate-limits\"}",
new Dictionary<string, string>(),
"application/json");
httpClient.Send(Args.Request, Args.CancellationToken).Returns(Task.FromResult(response));
var connection = new Connection(new ProductHeaderValue("OctokitTests"),
_exampleUri,
Substitute.For<ICredentialStore>(),
httpClient,
Substitute.For<IJsonSerializer>());

var exception = await Assert.ThrowsAsync<AbuseException>(
() => connection.GetResponse<string>(new Uri("endpoint", UriKind.Relative)));

Assert.Equal("Request Forbidden - Abuse Detection", exception.Message);
}
}

public class TheGetHtmlMethod
Expand Down
1 change: 1 addition & 0 deletions Octokit.Tests/Octokit.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@
<Compile Include="Clients\UserEmailsClientTests.cs" />
<Compile Include="Clients\WatchedClientTests.cs" />
<Compile Include="Clients\FollowersClientTests.cs" />
<Compile Include="Exceptions\AbuseExceptionTests.cs" />
<Compile Include="Exceptions\ApiErrorTests.cs" />
<Compile Include="Exceptions\ApiExceptionTests.cs" />
<Compile Include="Exceptions\ApiValidationExceptionTests.cs" />
Expand Down
109 changes: 109 additions & 0 deletions Octokit/Exceptions/AbuseException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Runtime.Serialization;
using System.Security;

namespace Octokit
{
/// <summary>
/// Represents a subset of the HTTP 403 - Forbidden response returned from the API when the forbidden response is related to an abuse detection mechanism.
/// Containts the amount of seconds after which it's safe to retry the request.
/// </summary>
#if !NETFX_CORE
[Serializable]
#endif
[SuppressMessage("Microsoft.Design", "CA1032:ImplementStandardExceptionConstructors",
Justification = "These exceptions are specific to the GitHub API and not general purpose exceptions")]
public class AbuseException : ForbiddenException
{
private readonly int? RetrySecondsDefault = null;
Copy link
Contributor

Choose a reason for hiding this comment

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

Missing a whitespace line after this line

/// <summary>
/// Constructs an instance of AbuseException
/// </summary>
/// <param name="response">The HTTP payload from the server</param>
public AbuseException(IResponse response) : this(response, null)
{
}

/// <summary>
/// Constructs an instance of AbuseException
/// </summary>
/// <param name="response">The HTTP payload from the server</param>
/// <param name="innerException">The inner exception</param>
public AbuseException(IResponse response, Exception innerException)
: base(response, innerException)
{
Debug.Assert(response != null && response.StatusCode == HttpStatusCode.Forbidden,
"AbuseException created with wrong status code");

SetRetryAfterSeconds(response);
}

private void SetRetryAfterSeconds(IResponse response)
Copy link
Contributor

@ryangribble ryangribble Jan 9, 2017

Choose a reason for hiding this comment

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

To me it seems a bit overkill to have a property, default property and 2 private functions, all related to pulling the value out of the headers and parsing it. I think maintainability would be higher if it was a single function called from the ctor that took the HttpResponse as input and returned the int? parsed value. It also eliminates the need to declare a default value, as the function could just return null at the end if all the TrypGetHeader TryParse and >= 0 tests didnt satisfy

Im happy to discuss if you feel things are more granularly testable with the current implementation but this was my initial thought around maintainability/readability...

Copy link
Contributor Author

@SeanKilleen SeanKilleen Jan 9, 2017

Choose a reason for hiding this comment

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

@ryangribble sure, makes sense. I'll clean it up and compact it so you can take a second pass.

{
string secondsValue;

// ReSharper disable once ConvertIfStatementToConditionalTernaryExpression
if (response.Headers.TryGetValue("Retry-After", out secondsValue))
{
RetryAfterSeconds = ParseRetryAfterSeconds(secondsValue);
}

else
{
RetryAfterSeconds = RetrySecondsDefault;
}
}

private int? ParseRetryAfterSeconds(string retryAfterString)
{
if (string.IsNullOrWhiteSpace(retryAfterString))
{
return RetrySecondsDefault;
}

int retrySeconds;
if (int.TryParse(retryAfterString, out retrySeconds))
{
return retrySeconds < 0 ? RetrySecondsDefault : retrySeconds;
}

return RetrySecondsDefault;
}

public int? RetryAfterSeconds { get; private set; }

public override string Message
{
get { return ApiErrorMessageSafe ?? "Request Forbidden - Abuse Detection"; }
}

#if !NETFX_CORE
/// <summary>
/// Constructs an instance of AbuseException
/// </summary>
/// <param name="info">
/// The <see cref="SerializationInfo"/> that holds the
/// serialized object data about the exception being thrown.
/// </param>
/// <param name="context">
/// The <see cref="StreamingContext"/> that contains
/// contextual information about the source or destination.
/// </param>
protected AbuseException(SerializationInfo info, StreamingContext context)
: base(info, context)
{
}

[SecurityCritical]
public override void GetObjectData(SerializationInfo info, StreamingContext context)
{
base.GetObjectData(info, context);
info.AddValue("RetryAfterSeconds", RetryAfterSeconds);
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Extra whitespace line

#endif
}
}
22 changes: 17 additions & 5 deletions Octokit/Http/Connection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -611,11 +611,23 @@ static Exception GetExceptionForUnauthorized(IResponse response)
static Exception GetExceptionForForbidden(IResponse response)
{
string body = response.Body as string ?? "";
return body.Contains("rate limit exceeded")
? new RateLimitExceededException(response)
: body.Contains("number of login attempts exceeded")
? new LoginAttemptsExceededException(response)
: new ForbiddenException(response);

if (body.Contains("rate limit exceeded"))
{
return new RateLimitExceededException(response);
}

if (body.Contains("number of login attempts exceeded"))
{
return new LoginAttemptsExceededException(response);
}

if (body.Contains("abuse-rate-limits") || body.Contains("abuse detection mechanism"))
{
return new AbuseException(response);
}

return new ForbiddenException(response);
}

internal static TwoFactorType ParseTwoFactorType(IResponse restResponse)
Expand Down
1 change: 1 addition & 0 deletions Octokit/Octokit-Mono.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,7 @@
<Compile Include="Clients\RepositoryTrafficClient.cs" />
<Compile Include="Helpers\ExcludeFromPaginationConventionTest.cs" />
<Compile Include="Models\Request\IssueCommentRequest.cs" />
<Compile Include="Exceptions\AbuseException.cs" />
</ItemGroup>
<Import Project="$(MSBuildBinPath)\Microsoft.CSharp.targets" />
</Project>
1 change: 1 addition & 0 deletions Octokit/Octokit-MonoAndroid.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,7 @@
<Compile Include="Clients\RepositoryTrafficClient.cs" />
<Compile Include="Helpers\ExcludeFromPaginationConventionTest.cs" />
<Compile Include="Models\Request\IssueCommentRequest.cs" />
<Compile Include="Exceptions\AbuseException.cs" />
</ItemGroup>
<Import Project="$(MSBuildExtensionsPath)\Novell\Novell.MonoDroid.CSharp.targets" />
</Project>
Loading