Skip to content

Commit

Permalink
Improved handling of HTTP statuses (#14)
Browse files Browse the repository at this point in the history
  • Loading branch information
cuongph87 authored and jopmiddelkamp committed Apr 25, 2024
1 parent 3f3342b commit c03657e
Show file tree
Hide file tree
Showing 10 changed files with 246 additions and 58 deletions.
101 changes: 99 additions & 2 deletions stellar-dotnet-sdk-test/ServerTest.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using Moq.Language;
using stellar_dotnet_sdk;
using stellar_dotnet_sdk.federation;
using stellar_dotnet_sdk.requests;
using stellar_dotnet_sdk.responses;

namespace stellar_dotnet_sdk_test;
Expand Down Expand Up @@ -56,13 +60,27 @@ public static HttpResponseMessage ResponseMessage(HttpStatusCode statusCode)
};
}

public static HttpResponseMessage ResponseMessage(HttpStatusCode statusCode, string content)
public static HttpResponseMessage ResponseMessage(
HttpStatusCode statusCode,
string content,
IDictionary<string, IEnumerable<string>>? headers = null)
{
return new HttpResponseMessage
var response = new HttpResponseMessage
{
StatusCode = statusCode,
Content = new StringContent(content)
};

if (headers == null)
return response;

foreach (var header in headers)
if (header.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase))
response.Content.Headers.ContentType = new MediaTypeHeaderValue(header.Value.First());
else
response.Headers.TryAddWithoutValidation(header.Key, header.Value);

return response;
}

private static Transaction BuildTransaction()
Expand Down Expand Up @@ -348,6 +366,85 @@ public void TestSubmitInvokeContractTransaction()
Assert.AreEqual(invokeContractOperation.Auth.Length, 0);
}


[TestMethod]
public async Task TestSubmitTransactionTooManyRequestsWithRetryAfterInt()
{
When().Returns(ResponseMessage(
HttpStatusCode.TooManyRequests,
"",
new Dictionary<string, IEnumerable<string>>
{
{ "Retry-After", new[] { "10" } }
}));

var exception = await Assert.ThrowsExceptionAsync<TooManyRequestsException>(
() => _server.SubmitTransaction(
BuildTransaction(),
new SubmitTransactionOptions { SkipMemoRequiredCheck = true }));

Assert.AreEqual(10, exception.RetryAfter);
}

[TestMethod]
public async Task TestSubmitTransactionTooManyRequestsWithRetryAfterDateTime()
{
When().Returns(ResponseMessage(
HttpStatusCode.TooManyRequests,
"",
new Dictionary<string, IEnumerable<string>>
{
{ "Retry-After", new[] { JsonSerializer.Serialize(DateTime.UtcNow.AddSeconds(10)).Trim('"') } }
}));


var exception = await Assert.ThrowsExceptionAsync<TooManyRequestsException>(
() => _server.SubmitTransaction(
BuildTransaction(),
new SubmitTransactionOptions { SkipMemoRequiredCheck = true }
));

Assert.IsTrue(exception.RetryAfter is >= 7 and <= 10, "The RetryAfter value is outside the expected range.");
}

[TestMethod]
public async Task TestSubmitTransactionServiceUnavailableWithRetryAfterInt()
{
When().Returns(ResponseMessage(
HttpStatusCode.ServiceUnavailable,
"",
new Dictionary<string, IEnumerable<string>>
{
{ "Retry-After", new[] { "10" } }
}));

var exception = await Assert.ThrowsExceptionAsync<ServiceUnavailableException>(
() => _server.SubmitTransaction(
BuildTransaction(),
new SubmitTransactionOptions { SkipMemoRequiredCheck = true }));

Assert.AreEqual(10, exception.RetryAfter);
}

[TestMethod]
public async Task TestSubmitTransactionServiceUnavailableWithRetryAfterDateTime()
{
When().Returns(ResponseMessage(
HttpStatusCode.ServiceUnavailable,
"",
new Dictionary<string, IEnumerable<string>>
{
{ "Retry-After", new[] { JsonSerializer.Serialize(DateTime.UtcNow.AddSeconds(10)).Trim('"') } }
}));

var exception = await Assert.ThrowsExceptionAsync<ServiceUnavailableException>(
() => _server.SubmitTransaction(
BuildTransaction(),
new SubmitTransactionOptions { SkipMemoRequiredCheck = true }));

Assert.IsTrue(exception.RetryAfter is >= 7 and <= 10, "The RetryAfter value is outside the expected range.");
}

public class FakeHttpMessageHandler : HttpMessageHandler
{
public Uri RequestUri { get; private set; }
Expand Down
18 changes: 9 additions & 9 deletions stellar-dotnet-sdk-test/SorobanServerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,18 @@ public class SorobanServerTest
private readonly SorobanServer _sorobanServer = new("https://soroban-testnet.stellar.org");

private readonly KeyPair _sourceAccount =
KeyPair.FromSecretSeed("SDR4PTKMR5TAQQCL3RI2MLXXSXQDIR7DCAONQNQP6UCDZCD4OVRWXUHI");
KeyPair.FromSecretSeed("SBQZZETKBHMRVNPEM7TMYAXORIRIDBBS6HD43C3PFH75SI54QAC6YTE2");

private readonly KeyPair _targetAccount =
KeyPair.FromSecretSeed("SDBNUIC2JMIYKGLJUFI743AQDWPBOWKG42GADHEY3FQDTQLJADYPQZTP");
KeyPair.FromSecretSeed("SBV33ITENGZRQ3UEUY5XD3NOBHHSGZY2ADF2OQ7JC2FR2S3BV3DSHEGC");

private Asset _asset =
new AssetTypeCreditAlphaNum4("BBB", "GARRDNS77ZSI6PPXRBWTHIVX4RS2ULVBKNJXFRV77AZUNLDUNV2NAHJA");
new AssetTypeCreditAlphaNum4("XXX", "GC5UTAORS4ASIS5H6M4WNFZECGWXJHET5VRPVYC7UM44CM62OA2RQEPS");

// "GC3TDMFTMYZY2G4C77AKAVC3BR4KL6WMQ6K2MHISKDH2OHRFS7CVVEAF"
// "GDUFELVZEZ3CX5PLYJAGPZ7CIM3HTVAD2JRHKXTGK4N5B2ADCALW7NGW"
private string TargetAccountId => _targetAccount.AccountId;

// "GARRDNS77ZSI6PPXRBWTHIVX4RS2ULVBKNJXFRV77AZUNLDUNV2NAHJA";
// "GC5UTAORS4ASIS5H6M4WNFZECGWXJHET5VRPVYC7UM44CM62OA2RQEPS";
private string SourceAccountId => _sourceAccount.AccountId;

[TestInitialize]
Expand All @@ -66,7 +66,7 @@ public async Task Setup()
await TestNetUtil.CheckAndCreateAccountOnTestnet(SourceAccountId);
await TestNetUtil.CheckAndCreateAccountOnTestnet(TargetAccountId);

_asset = new AssetTypeCreditAlphaNum4("AAA", SourceAccountId);
_asset = new AssetTypeCreditAlphaNum4("XXX", SourceAccountId);
}

[TestCleanup]
Expand All @@ -90,7 +90,7 @@ public async Task TestGetNetwork()
var response = await _sorobanServer.GetNetwork();
Assert.AreEqual("https://friendbot.stellar.org/", response.FriendbotUrl);
Assert.AreEqual("Test SDF Network ; September 2015", response.Passphrase);
Assert.AreEqual(20, response.ProtocolVersion);
Assert.AreEqual(21, response.ProtocolVersion);
}

[TestMethod]
Expand Down Expand Up @@ -790,7 +790,7 @@ public async Task TestGetLedgerEntriesOfTypeData()

var ledgerKeyData = new LedgerKey[]
{
new LedgerKeyData(SourceAccountId, "passkey")
new LedgerKeyData(TargetAccountId, "passkey")
};
var dataResponse = await _sorobanServer.GetLedgerEntries(ledgerKeyData);

Expand All @@ -807,7 +807,7 @@ public async Task TestGetLedgerEntriesOfTypeData()
Assert.AreEqual(0U, ledgerEntry.LiveUntilLedger);
Assert.IsTrue(ledgerEntry.LastModifiedLedgerSeq > 0);
Assert.AreEqual("it's a secret", Encoding.UTF8.GetString(ledgerEntry.DataValue));
Assert.AreEqual(SourceAccountId, ledgerKey.Account.AccountId);
Assert.AreEqual(TargetAccountId, ledgerKey.Account.AccountId);
Assert.IsNull(ledgerEntry.LedgerExtensionV1);
Assert.IsNull(ledgerEntry.DataExtension);
}
Expand Down
9 changes: 4 additions & 5 deletions stellar-dotnet-sdk-test/SponsorshipTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,15 @@ namespace stellar_dotnet_sdk_test;
public class SponsorshipTest
{
private const string DataName = "my secret";
private readonly Server _server = new("https://horizon-testnet.stellar.org");

private readonly KeyPair _sponsoredAccount =
KeyPair.FromSecretSeed("SDBNUIC2JMIYKGLJUFI743AQDWPBOWKG42GADHEY3FQDTQLJADYPQZTP");
private readonly Server _server = new("https://horizon-testnet.stellar.org");private readonly KeyPair _sponsoredAccount =
KeyPair.FromSecretSeed("SBV33ITENGZRQ3UEUY5XD3NOBHHSGZY2ADF2OQ7JC2FR2S3BV3DSHEGC");

private readonly KeyPair _sponsoringAccount =
KeyPair.FromSecretSeed("SDR4PTKMR5TAQQCL3RI2MLXXSXQDIR7DCAONQNQP6UCDZCD4OVRWXUHI");
KeyPair.FromSecretSeed("SBQZZETKBHMRVNPEM7TMYAXORIRIDBBS6HD43C3PFH75SI54QAC6YTE2");

private Asset _assetA =
new AssetTypeCreditAlphaNum4("AAA", "GARRDNS77ZSI6PPXRBWTHIVX4RS2ULVBKNJXFRV77AZUNLDUNV2NAHJA");
new AssetTypeCreditAlphaNum4("XXX", "GC5UTAORS4ASIS5H6M4WNFZECGWXJHET5VRPVYC7UM44CM62OA2RQEPS");

// "GC3TDMFTMYZY2G4C77AKAVC3BR4KL6WMQ6K2MHISKDH2OHRFS7CVVEAF";
private string SponsoredId => _sponsoredAccount.AccountId;
Expand Down
64 changes: 37 additions & 27 deletions stellar-dotnet-sdk/Server.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Threading.Tasks;
Expand Down Expand Up @@ -69,7 +71,7 @@ public Server(string uri)

public void Dispose()
{
if (_internalHttpClient) _httpClient?.Dispose();
if (_internalHttpClient) _httpClient.Dispose();
}

public RootResponse Root()
Expand All @@ -89,8 +91,6 @@ public async Task<RootResponse> RootAsync()
/// Submit a transaction to the network.
/// This method will check if any of the destination accounts require a memo.
/// </summary>
/// <param name="transaction"></param>
/// <returns></returns>
public Task<SubmitTransactionResponse?> SubmitTransaction(Transaction transaction)
{
var options = new SubmitTransactionOptions { SkipMemoRequiredCheck = false };
Expand All @@ -102,9 +102,6 @@ public async Task<RootResponse> RootAsync()
/// This method will check if any of the destination accounts require a memo. Change the SkipMemoRequiredCheck
/// options to change this behaviour.
/// </summary>
/// <param name="transaction"></param>
/// <param name="options"></param>
/// <returns></returns>
public Task<SubmitTransactionResponse?> SubmitTransaction(Transaction transaction, SubmitTransactionOptions options)
{
return SubmitTransaction(transaction.ToEnvelopeXdrBase64(), options);
Expand All @@ -114,8 +111,6 @@ public async Task<RootResponse> RootAsync()
/// Submit a transaction to the network.
/// This method will check if any of the destination accounts require a memo.
/// </summary>
/// <param name="transactionEnvelopeBase64"></param>
/// <returns></returns>
public Task<SubmitTransactionResponse?> SubmitTransaction(string transactionEnvelopeBase64)
{
var options = new SubmitTransactionOptions { SkipMemoRequiredCheck = false };
Expand All @@ -140,10 +135,8 @@ public async Task<RootResponse> RootAsync()
/// This method will check if any of the destination accounts require a memo. Change the SkipMemoRequiredCheck
/// options to change this behaviour.
/// </summary>
/// <param name="transactionEnvelopeBase64"></param>
/// <param name="options"></param>
/// <returns></returns>
public async Task<SubmitTransactionResponse?> SubmitTransaction(string transactionEnvelopeBase64,
public async Task<SubmitTransactionResponse?> SubmitTransaction(
string transactionEnvelopeBase64,
SubmitTransactionOptions options)
{
if (!options.SkipMemoRequiredCheck)
Expand All @@ -158,29 +151,49 @@ public async Task<RootResponse> RootAsync()
await CheckMemoRequired(tx);
}

var transactionUri = new UriBuilder(_serverUri).SetPath("/transactions").Uri;
var transactionUriBuilder = new UriBuilder(_serverUri);

var path = _serverUri.AbsolutePath.TrimEnd('/');
transactionUriBuilder.SetPath($"{path}/transactions");

var paramsPairs = new List<KeyValuePair<string, string>>
{
new("tx", transactionEnvelopeBase64)
};

var response = await _httpClient.PostAsync(transactionUri, new FormUrlEncodedContent(paramsPairs.ToArray()));

var response =
await _httpClient.PostAsync(transactionUriBuilder.Uri, new FormUrlEncodedContent(paramsPairs.ToArray()));
var responseString = await response.Content.ReadAsStringAsync();

if (options.EnsureSuccess && !response.IsSuccessStatusCode)
{
var responseString = string.Empty;
if (response.Content != null) responseString = await response.Content.ReadAsStringAsync();

throw new ConnectionErrorException(
$"Status code ({response.StatusCode}) is not success.{(!string.IsNullOrEmpty(responseString) ? " Content: " + responseString : "")}");
}

if (response.Content == null) return null;
switch (response.StatusCode)
{
var responseString = await response.Content.ReadAsStringAsync();
var submitTransactionResponse = JsonSingleton.GetInstance<SubmitTransactionResponse>(responseString);
return submitTransactionResponse;
case HttpStatusCode.OK:
case HttpStatusCode.BadRequest:
var submitTransactionResponse = JsonSingleton.GetInstance<SubmitTransactionResponse>(
responseString);
return submitTransactionResponse;
case HttpStatusCode.ServiceUnavailable:
throw new ServiceUnavailableException(
response.Headers.Contains("Retry-After")
? response.Headers.GetValues("Retry-After").First()
: null
);
case HttpStatusCode.TooManyRequests:
throw new TooManyRequestsException(
response.Headers.Contains("Retry-After")
? response.Headers.GetValues("Retry-After").First()
: null
);
case HttpStatusCode.GatewayTimeout:
throw new SubmitTransactionTimeoutResponseException();
default:
throw new SubmitTransactionUnknownResponseException(response.StatusCode, responseString);
}
}

Expand All @@ -191,9 +204,6 @@ public async Task<RootResponse> RootAsync()
/// It will sequentially load each destination account and check if it has the data field
/// <c>config.memo_required</c> set to <c>"MQ=="</c>.
/// </summary>
/// <param name="transaction"></param>
/// <returns></returns>
/// <exception cref="AccountRequiresMemoException"></exception>
public async Task CheckMemoRequired(TransactionBase transaction)
{
var tx = GetTransactionToCheck(transaction);
Expand All @@ -217,9 +227,9 @@ public async Task CheckMemoRequired(TransactionBase transaction)
try
{
var account = await Accounts.Account(destination);
if (!account.Data.ContainsKey(AccountRequiresMemoKey)) continue;
if (!account.Data.TryGetValue(AccountRequiresMemoKey, out var value)) continue;

if (account.Data[AccountRequiresMemoKey] == AccountRequiresMemo)
if (value == AccountRequiresMemo)
throw new AccountRequiresMemoException("Account requires memo");
}
catch (HttpResponseException ex)
Expand Down
2 changes: 1 addition & 1 deletion stellar-dotnet-sdk/SorobanServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ public Task<GetNetworkResponse> GetNetwork()
/// </returns>
public Task<GetTransactionResponse> GetTransaction(string txHash)
{
return SendRequest<object, GetTransactionResponse>("getTransaction", new[] { txHash });
return SendRequest<object, GetTransactionResponse>("getTransaction", new { hash = txHash });
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using System;

namespace stellar_dotnet_sdk;

public class SubmitTransactionTimeoutResponseException : Exception
{
}
17 changes: 17 additions & 0 deletions stellar-dotnet-sdk/SubmitTransactionUnknownResponseException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System;
using System.Net;

namespace stellar_dotnet_sdk;

public class SubmitTransactionUnknownResponseException : Exception
{
public SubmitTransactionUnknownResponseException(HttpStatusCode code, string body) :
base($"Unknown response from Horizon - code: {code} - body: {body}")
{
Body = body;
Code = code;
}

public HttpStatusCode Code { get; }
public string Body { get; }
}
Loading

0 comments on commit c03657e

Please sign in to comment.