Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,14 @@ Tests use NUnit + Moq and target net10.0. Three categories gate what runs: `Unit

Entry point is `ServiceCollectionExtensions.AddFinanceNet()` (src/Extensions), which wires everything:

- Four scoped services in `src/Services/` behind public interfaces in `src/Interfaces/`: `YahooFinanceService`, `AlphaVantageService`, `XetraService`, `DataHubService`. Services are `internal`; only interfaces, models, enums, config (`FinanceNetConfiguration`), and the exception are public API.
- Four scoped services in `src/Services/` behind public interfaces in `src/Interfaces/`: `YahooFinanceService`, `AlphaVantageService`, `XetraService`, `DataHubService`. Services are `internal`; only interfaces, models, enums, config (`FinanceNetConfiguration`), and the exceptions are public API.
- One named `HttpClient` per provider (names in `src/Constants.cs`, which also holds all provider URLs). Each client gets a randomized User-Agent (`Helper.CreateRandomUserAgent`).
- A shared Polly retry policy (`PollyPolicyFactory`) registered in a `PolicyRegistry` under `Constants.DefaultHttpRetryPolicy`; services resolve it from the registry and wrap their HTTP calls with it. Retry count and back-off base (`HttpRetryCount`, `HttpRetrySleepTime`) come from `FinanceNetConfiguration`; `HttpTimeout` is the HTTP client timeout only and must not be reused as the back-off base. The back-off grows exponentially from the base, is capped at `PollyPolicyFactory.MaxRetryDelaySecs` (30s) per attempt, and carries jitter of up to one full delay.
- Internal DTOs (`src/Models/*/Dtos/`) are converted to the public models (`src/Models/*/`) by hand-written extension-method mappers in `src/Mappings/` (`YahooQuoteMapper.ToQuote`, `XetraInstrumentMapper.ToInstrument`). The `*Mapping.cs` classes in the same folder are CsvHelper `ClassMap`s, not object-to-object mappers.

Yahoo requires session state: `YahooSessionState` and `YahooSessionManager` (singletons in `src/Utilities/`) fetch consent cookies and an API "crumb", guarded by a semaphore, valid for 6 hours. The Yahoo HttpClient shares the session's `CookieContainer`. HTML parsing uses AngleSharp with XPath (`YahooHtmlParser`); Alpha Vantage JSON parsing is in `AlphaVantageParser`; CSV parsing uses CsvHelper.

All failures surface as `FinanceNetException`. A provider that answered correctly but with no rows throws `FinanceNetNoDataException` (which derives from it) and is deliberately **not** wrapped, so `InnerException` is null there; the retry policy skips it. Everything else - transport errors, an unexpected page, malformed content - stays retryable and keeps its inner exception.
All failures surface as `FinanceNetException`. A provider that answered correctly but with no rows throws `FinanceNetNoDataException` (which derives from it) and is deliberately **not** wrapped, so `InnerException` is null there; the retry policy skips it. The same applies to `FinanceNetAccessDeniedException`, thrown when the provider refuses the request for the account (Alpha Vantage premium-only endpoint, invalid API key). Everything else - transport errors, an unexpected page, malformed content - stays retryable and keeps its inner exception.

## Conventions

Expand Down
1 change: 1 addition & 0 deletions src/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ internal static class Constants
// Messages
public const string ApiResponseLimitExceeded = "higher API call volume";
public const string ApiResponseApiKeyInvalid = "apikey is invalid";
public const string ApiResponsePremiumEndpoint = "premium endpoint";

#region Yahoo Finance

Expand Down
41 changes: 41 additions & 0 deletions src/Exceptions/FinanceNetAccessDeniedException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System;

namespace Finance.Net.Exceptions;

/// <summary>
/// Represents a provider refusing the request for the configured account - an endpoint
/// that needs a premium plan, an invalid or missing API key.
/// </summary>
/// <remarks>
/// This is a permanent answer, not a transient transport failure: the retry policy does
/// not retry it. It derives from <see cref="FinanceNetException"/>, so existing handlers
/// that catch <see cref="FinanceNetException"/> keep working unchanged.
/// </remarks>
public class FinanceNetAccessDeniedException : FinanceNetException
{
/// <summary>
/// Initializes a new instance of the <see cref="FinanceNetAccessDeniedException"/> class.
/// </summary>
public FinanceNetAccessDeniedException()
{
}

/// <summary>
/// Initializes a new instance of the <see cref="FinanceNetAccessDeniedException"/> class with a specified error message.
/// </summary>
/// <param name="message">The message that describes the error.</param>
public FinanceNetAccessDeniedException(string message)
: base(message)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="FinanceNetAccessDeniedException"/> class with a specified error message and a reference to the inner exception that is the cause of this exception.
/// </summary>
/// <param name="message">The message that describes the error.</param>
/// <param name="inner">The exception that is the cause of the current exception.</param>
public FinanceNetAccessDeniedException(string message, Exception inner)
: base(message, inner)
{
}
}
34 changes: 26 additions & 8 deletions src/Services/AlphaVantageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ public AlphaVantageService(
httpResponse.EnsureSuccessStatusCode();

var jsonResponse = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
ThrowIfRejected(jsonResponse);
if (jsonResponse.Contains(Constants.ApiResponseLimitExceeded))
{
throw new FinanceNetException($"{Constants.ApiResponseLimitExceeded} for {symbol}");
Expand All @@ -97,7 +98,7 @@ public AlphaVantageService(
{
throw;
}
catch (Exception ex) when (ex is not FinanceNetNoDataException)
catch (Exception ex) when (ex is not (FinanceNetNoDataException or FinanceNetAccessDeniedException))
{
throw new FinanceNetException($"No overview found for {symbol}", ex);
}
Expand Down Expand Up @@ -126,6 +127,7 @@ public async Task<IEnumerable<Record>> GetRecordsAsync(string symbol, DateTime?
return await _retryPolicy.ExecuteAsync(async ct =>
{
var jsonResponse = await Helper.FetchJsonDocumentAsync(httpClient, _logger, url, ct).ConfigureAwait(false);
ThrowIfRejected(jsonResponse);
if (jsonResponse.Contains(Constants.ApiResponseLimitExceeded))
{
throw new FinanceNetException($"{Constants.ApiResponseLimitExceeded} for {symbol}");
Expand All @@ -138,7 +140,7 @@ public async Task<IEnumerable<Record>> GetRecordsAsync(string symbol, DateTime?
{
throw;
}
catch (Exception ex) when (ex is not FinanceNetNoDataException)
catch (Exception ex) when (ex is not (FinanceNetNoDataException or FinanceNetAccessDeniedException))
{
throw new FinanceNetException($"No Record found for {symbol}", ex);
}
Expand Down Expand Up @@ -197,6 +199,7 @@ private async Task<List<IntradayRecord>> GetIntradayRecordsByMonthAsync(string s
response.EnsureSuccessStatusCode();

var jsonResponse = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
ThrowIfRejected(jsonResponse);
if (jsonResponse.Contains(Constants.ApiResponseLimitExceeded))
{
throw new FinanceNetException($"{Constants.ApiResponseLimitExceeded} for {symbol}");
Expand All @@ -209,7 +212,7 @@ private async Task<List<IntradayRecord>> GetIntradayRecordsByMonthAsync(string s
{
throw;
}
catch (Exception ex) when (ex is not FinanceNetNoDataException)
catch (Exception ex) when (ex is not (FinanceNetNoDataException or FinanceNetAccessDeniedException))
{
throw new FinanceNetException($"No intraday record found for {symbol}", ex);
}
Expand Down Expand Up @@ -240,14 +243,11 @@ public async Task<IEnumerable<ForexRecord>> GetForexRecordsAsync(string currency
return await _retryPolicy.ExecuteAsync(async ct =>
{
var jsonResponse = await Helper.FetchJsonDocumentAsync(httpClient, _logger, url, ct).ConfigureAwait(false);
ThrowIfRejected(jsonResponse);
if (jsonResponse.Contains(Constants.ApiResponseLimitExceeded))
{
throw new FinanceNetException($"{Constants.ApiResponseLimitExceeded} for {currency1} /{currency2}");
}
if (jsonResponse.Contains(Constants.ApiResponseApiKeyInvalid))
{
throw new FinanceNetException($"{Constants.ApiResponseApiKeyInvalid}");
}
var result = AlphaVantageParser.ParseForexRecords(currency1, currency2, startDate, endDate, jsonResponse, _logger);
return result.IsNullOrEmpty() ? throw new FinanceNetNoDataException($"Alpha Vantage returned no forex records for {currency1}/{currency2}") : result;
}, token).ConfigureAwait(false);
Expand All @@ -256,9 +256,27 @@ public async Task<IEnumerable<ForexRecord>> GetForexRecordsAsync(string currency
{
throw;
}
catch (Exception ex) when (ex is not FinanceNetNoDataException)
catch (Exception ex) when (ex is not (FinanceNetNoDataException or FinanceNetAccessDeniedException))
{
throw new FinanceNetException($"No forex record found for {currency1}, {currency2}", ex);
}
}

/// <summary>
/// Alpha Vantage answers a refused request with HTTP 200 and a JSON message instead of data.
/// A premium-only endpoint or a bad API key will not change on retry, so fail immediately.
/// </summary>
private static void ThrowIfRejected(string jsonResponse)
{
if (jsonResponse.Contains(Constants.ApiResponsePremiumEndpoint))
{
throw new FinanceNetAccessDeniedException(
$"Alpha Vantage rejected the request: this is a {Constants.ApiResponsePremiumEndpoint} and needs a premium plan");
}
if (jsonResponse.Contains(Constants.ApiResponseApiKeyInvalid))
{
throw new FinanceNetAccessDeniedException(
$"Alpha Vantage rejected the request: {Constants.ApiResponseApiKeyInvalid} or missing");
}
}
}
7 changes: 4 additions & 3 deletions src/Utilities/PollyPolicyFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ internal static class PollyPolicyFactory
public static AsyncRetryPolicy GetRetryPolicy<T>(int retryCount, int baseWaitTimeSecs, ILogger<T> logger)
{
return Policy
// A provider that answered with no data has given a permanent answer - retrying
// it burns the whole back-off budget and cannot change the outcome.
.Handle<Exception>(ex => ex is not FinanceNetNoDataException)
// A provider that answered with no data, or refused the request for this account,
// has given a permanent answer - retrying it burns the whole back-off budget and
// cannot change the outcome.
.Handle<Exception>(ex => ex is not (FinanceNetNoDataException or FinanceNetAccessDeniedException))
.WaitAndRetryAsync(
retryCount,
retryAttempt => GetRetryDelay(retryAttempt, baseWaitTimeSecs),
Expand Down
118 changes: 118 additions & 0 deletions tests/Services/AlphaVantageRejectedRequestTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using System;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Finance.Net.Exceptions;
using Finance.Net.Services;
using Finance.Net.Utilities;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Moq.Protected;
using NUnit.Framework;
using Polly;
using Polly.Registry;

namespace Finance.Net.Tests.Services;

/// <summary>
/// Alpha Vantage refusing a request for the account (premium-only endpoint, invalid API key)
/// is a permanent answer: retrying cannot change it. It must surface as
/// <see cref="FinanceNetAccessDeniedException"/> and cost exactly one request.
/// </summary>
[TestFixture]
[Category("Unit")]
public class AlphaVantageRejectedRequestTests
{
private const int RetryCount = 3;
private const string PremiumResponse =
"{\"Information\": \"Thank you for using Alpha Vantage! This is a premium endpoint. You may subscribe to any of the premium plans at https://www.alphavantage.co/premium/ to instantly unlock all premium endpoints\"}";
private const string InvalidApiKeyResponse =
"{\"Error Message\": \"the parameter apikey is invalid or missing. Please claim your free API key on (https://www.alphavantage.co/support/#api-key). It should take less than 20 seconds.\"}";

private int _requestCount;
private AlphaVantageService _service;

private void SetUpResponse(string body)
{
_requestCount = 0;
var mockHandler = new Mock<HttpMessageHandler>();
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
.Returns(() =>
{
_requestCount++;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
});
});

var mockHttpClientFactory = new Mock<IHttpClientFactory>();
mockHttpClientFactory.Setup(e => e.CreateClient(It.IsAny<string>())).Returns(() => new HttpClient(mockHandler.Object));

IAsyncPolicy policy = PollyPolicyFactory.GetRetryPolicy<AlphaVantageService>(RetryCount, 0, null);
var mockPolicyRegistry = new Mock<IReadOnlyPolicyRegistry<string>>();
mockPolicyRegistry.Setup(registry => registry.TryGet(Constants.DefaultHttpRetryPolicy, out policy)).Returns(true);

var mockOptions = new Mock<IOptions<FinanceNetConfiguration>>();
mockOptions.Setup(x => x.Value).Returns(new FinanceNetConfiguration());

_service = new AlphaVantageService(
Mock.Of<ILogger<AlphaVantageService>>(),
mockHttpClientFactory.Object,
mockOptions.Object,
mockPolicyRegistry.Object);
}

private static readonly DateTime StartDate = new(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc);

private static readonly object[] Calls =
[
new object[] { "GetRecordsAsync", (Func<AlphaVantageService, Task>)(s => s.GetRecordsAsync("IBM", StartDate)) },
new object[] { "GetOverviewAsync", (Func<AlphaVantageService, Task>)(s => s.GetOverviewAsync("IBM")) },
new object[] { "GetIntradayRecordsAsync", (Func<AlphaVantageService, Task>)(s => s.GetIntradayRecordsAsync("IBM", StartDate, StartDate.AddDays(1))) },
new object[] { "GetForexRecordsAsync", (Func<AlphaVantageService, Task>)(s => s.GetForexRecordsAsync("EUR", "USD", StartDate)) },
];

[TestCaseSource(nameof(Calls))]
public void PremiumEndpoint_FailsFastWithoutRetrying(string name, Func<AlphaVantageService, Task> call)
{
SetUpResponse(PremiumResponse);

var exception = Assert.CatchAsync<FinanceNetAccessDeniedException>(async () => await call(_service), name);
Assert.That(exception.Message, Does.Contain("premium endpoint"), name);
Assert.That(exception.InnerException, Is.Null, name);
Assert.That(_requestCount, Is.EqualTo(1), $"{name}: a premium-endpoint refusal was retried");
}

[TestCaseSource(nameof(Calls))]
public void InvalidApiKey_FailsFastWithoutRetrying(string name, Func<AlphaVantageService, Task> call)
{
SetUpResponse(InvalidApiKeyResponse);

var exception = Assert.CatchAsync<FinanceNetAccessDeniedException>(async () => await call(_service), name);
Assert.That(exception.Message, Does.Contain("apikey is invalid"), name);
Assert.That(_requestCount, Is.EqualTo(1), $"{name}: an invalid-API-key refusal was retried");
}

[Test]
public void ApiLimitExceeded_IsStillRetried()
{
// The per-minute limit clears on its own, so it stays a retryable failure.
SetUpResponse("{\"Information\": \"Please consider spreading out your free API requests more sparingly (1 request per second). You may subscribe to any of the premium plans at https://www.alphavantage.co/premium/ to lift the free key rate limit, or visit higher API call volume.\"}");

var exception = Assert.CatchAsync<FinanceNetException>(async () => await _service.GetRecordsAsync("IBM", StartDate));
Assert.That(exception, Is.Not.InstanceOf<FinanceNetAccessDeniedException>());
Assert.That(_requestCount, Is.EqualTo(RetryCount + 1));
}

[Test]
public void FinanceNetAccessDeniedException_IsAFinanceNetException()
{
Assert.That(new FinanceNetAccessDeniedException("denied"), Is.InstanceOf<FinanceNetException>());
}
}
Loading