diff --git a/CLAUDE.md b/CLAUDE.md index 7550d43..bb0a912 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/src/Constants.cs b/src/Constants.cs index d6d9ae2..5739236 100644 --- a/src/Constants.cs +++ b/src/Constants.cs @@ -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 diff --git a/src/Exceptions/FinanceNetAccessDeniedException.cs b/src/Exceptions/FinanceNetAccessDeniedException.cs new file mode 100644 index 0000000..423aa7d --- /dev/null +++ b/src/Exceptions/FinanceNetAccessDeniedException.cs @@ -0,0 +1,41 @@ +using System; + +namespace Finance.Net.Exceptions; + +/// +/// Represents a provider refusing the request for the configured account - an endpoint +/// that needs a premium plan, an invalid or missing API key. +/// +/// +/// This is a permanent answer, not a transient transport failure: the retry policy does +/// not retry it. It derives from , so existing handlers +/// that catch keep working unchanged. +/// +public class FinanceNetAccessDeniedException : FinanceNetException +{ + /// + /// Initializes a new instance of the class. + /// + public FinanceNetAccessDeniedException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public FinanceNetAccessDeniedException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception. + public FinanceNetAccessDeniedException(string message, Exception inner) + : base(message, inner) + { + } +} diff --git a/src/Services/AlphaVantageService.cs b/src/Services/AlphaVantageService.cs index 9e77a28..57eee20 100644 --- a/src/Services/AlphaVantageService.cs +++ b/src/Services/AlphaVantageService.cs @@ -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}"); @@ -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); } @@ -126,6 +127,7 @@ public async Task> 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}"); @@ -138,7 +140,7 @@ public async Task> 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); } @@ -197,6 +199,7 @@ private async Task> 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}"); @@ -209,7 +212,7 @@ private async Task> 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); } @@ -240,14 +243,11 @@ public async Task> 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); @@ -256,9 +256,27 @@ public async Task> 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); } } + + /// + /// 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. + /// + 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"); + } + } } diff --git a/src/Utilities/PollyPolicyFactory.cs b/src/Utilities/PollyPolicyFactory.cs index 8011d1c..a3a10b1 100644 --- a/src/Utilities/PollyPolicyFactory.cs +++ b/src/Utilities/PollyPolicyFactory.cs @@ -15,9 +15,10 @@ internal static class PollyPolicyFactory public static AsyncRetryPolicy GetRetryPolicy(int retryCount, int baseWaitTimeSecs, ILogger 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(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(ex => ex is not (FinanceNetNoDataException or FinanceNetAccessDeniedException)) .WaitAndRetryAsync( retryCount, retryAttempt => GetRetryDelay(retryAttempt, baseWaitTimeSecs), diff --git a/tests/Services/AlphaVantageRejectedRequestTests.cs b/tests/Services/AlphaVantageRejectedRequestTests.cs new file mode 100644 index 0000000..4a72fba --- /dev/null +++ b/tests/Services/AlphaVantageRejectedRequestTests.cs @@ -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; + +/// +/// 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 +/// and cost exactly one request. +/// +[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(); + mockHandler + .Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(() => + { + _requestCount++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }); + }); + + var mockHttpClientFactory = new Mock(); + mockHttpClientFactory.Setup(e => e.CreateClient(It.IsAny())).Returns(() => new HttpClient(mockHandler.Object)); + + IAsyncPolicy policy = PollyPolicyFactory.GetRetryPolicy(RetryCount, 0, null); + var mockPolicyRegistry = new Mock>(); + mockPolicyRegistry.Setup(registry => registry.TryGet(Constants.DefaultHttpRetryPolicy, out policy)).Returns(true); + + var mockOptions = new Mock>(); + mockOptions.Setup(x => x.Value).Returns(new FinanceNetConfiguration()); + + _service = new AlphaVantageService( + Mock.Of>(), + 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)(s => s.GetRecordsAsync("IBM", StartDate)) }, + new object[] { "GetOverviewAsync", (Func)(s => s.GetOverviewAsync("IBM")) }, + new object[] { "GetIntradayRecordsAsync", (Func)(s => s.GetIntradayRecordsAsync("IBM", StartDate, StartDate.AddDays(1))) }, + new object[] { "GetForexRecordsAsync", (Func)(s => s.GetForexRecordsAsync("EUR", "USD", StartDate)) }, + ]; + + [TestCaseSource(nameof(Calls))] + public void PremiumEndpoint_FailsFastWithoutRetrying(string name, Func call) + { + SetUpResponse(PremiumResponse); + + var exception = Assert.CatchAsync(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 call) + { + SetUpResponse(InvalidApiKeyResponse); + + var exception = Assert.CatchAsync(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(async () => await _service.GetRecordsAsync("IBM", StartDate)); + Assert.That(exception, Is.Not.InstanceOf()); + Assert.That(_requestCount, Is.EqualTo(RetryCount + 1)); + } + + [Test] + public void FinanceNetAccessDeniedException_IsAFinanceNetException() + { + Assert.That(new FinanceNetAccessDeniedException("denied"), Is.InstanceOf()); + } +}