From b91b29f5d368859563b18431d1eec9fda5a24cd1 Mon Sep 17 00:00:00 2001 From: thorsten Date: Sun, 20 Sep 2026 07:59:20 +0200 Subject: [PATCH 1/2] Accept derived exceptions in the Alpha Vantage integration tests Assert.ThrowsAsync matches the exact type, so the negative cases turned red once #63 made a refused request throw FinanceNetAccessDeniedException. Co-Authored-By: Claude Fable 5.1 --- tests/IntegrationTests/AlphaVantageTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/IntegrationTests/AlphaVantageTests.cs b/tests/IntegrationTests/AlphaVantageTests.cs index 42708b1..29ff3b5 100644 --- a/tests/IntegrationTests/AlphaVantageTests.cs +++ b/tests/IntegrationTests/AlphaVantageTests.cs @@ -40,7 +40,7 @@ public async Task GetOverviewAsync(string symbol, bool shouldHave) } else { - Assert.ThrowsAsync(async () => await _service.GetOverviewAsync(symbol)); + Assert.CatchAsync(async () => await _service.GetOverviewAsync(symbol)); } } @@ -61,7 +61,7 @@ public async Task GetRecordsAsync(string symbol, bool shouldHave) } else { - Assert.ThrowsAsync(async () => await _service.GetRecordsAsync(symbol, DateTime.UtcNow.AddDays(-7))); + Assert.CatchAsync(async () => await _service.GetRecordsAsync(symbol, DateTime.UtcNow.AddDays(-7))); } } @@ -85,7 +85,7 @@ public async Task GetIntradayRecordsAsync(string symbol, EInterval eInterval, bo } else { - Assert.ThrowsAsync(async () => await _service.GetIntradayRecordsAsync(symbol, startDay, endDay, eInterval)); + Assert.CatchAsync(async () => await _service.GetIntradayRecordsAsync(symbol, startDay, endDay, eInterval)); } } @@ -101,7 +101,7 @@ public async Task GetForexRecordsAsync(string currency1, string currency2, bool } else { - Assert.ThrowsAsync(async () => await _service.GetForexRecordsAsync(currency1, currency2, DateTime.UtcNow.AddDays(-3))); + Assert.CatchAsync(async () => await _service.GetForexRecordsAsync(currency1, currency2, DateTime.UtcNow.AddDays(-3))); } } } From 0d621046a474d239da964618d79ac7ab3dcbf8f3 Mon Sep 17 00:00:00 2001 From: thorsten Date: Sun, 20 Sep 2026 07:59:21 +0200 Subject: [PATCH 2/2] Keep an Alpha Vantage rate-limit notice retryable The notice advertises "premium endpoints", so the "premium endpoint" marker classified a temporary rate limit as a permanent refusal. The marker now matches the full sentence. The notice also lacks the old "higher API call volume" wording, so GetOverviewAsync deserialized it to an empty object and reported no data without retrying. "rate limit" is now recognised as a retryable limit in all four methods. Co-Authored-By: Claude Fable 5.1 --- src/Constants.cs | 3 ++- src/Services/AlphaVantageService.cs | 18 +++++++++--------- .../AlphaVantageRejectedRequestTests.cs | 15 ++++++++------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/Constants.cs b/src/Constants.cs index 5739236..e16fea0 100644 --- a/src/Constants.cs +++ b/src/Constants.cs @@ -14,8 +14,9 @@ internal static class Constants // Messages public const string ApiResponseLimitExceeded = "higher API call volume"; + public const string ApiResponseRateLimit = "rate limit"; public const string ApiResponseApiKeyInvalid = "apikey is invalid"; - public const string ApiResponsePremiumEndpoint = "premium endpoint"; + public const string ApiResponsePremiumEndpoint = "This is a premium endpoint"; #region Yahoo Finance diff --git a/src/Services/AlphaVantageService.cs b/src/Services/AlphaVantageService.cs index 57eee20..9794ab4 100644 --- a/src/Services/AlphaVantageService.cs +++ b/src/Services/AlphaVantageService.cs @@ -81,7 +81,7 @@ public AlphaVantageService( var jsonResponse = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); ThrowIfRejected(jsonResponse); - if (jsonResponse.Contains(Constants.ApiResponseLimitExceeded)) + if (IsRateLimited(jsonResponse)) { throw new FinanceNetException($"{Constants.ApiResponseLimitExceeded} for {symbol}"); } @@ -128,7 +128,7 @@ public async Task> GetRecordsAsync(string symbol, DateTime? { var jsonResponse = await Helper.FetchJsonDocumentAsync(httpClient, _logger, url, ct).ConfigureAwait(false); ThrowIfRejected(jsonResponse); - if (jsonResponse.Contains(Constants.ApiResponseLimitExceeded)) + if (IsRateLimited(jsonResponse)) { throw new FinanceNetException($"{Constants.ApiResponseLimitExceeded} for {symbol}"); } @@ -200,7 +200,7 @@ private async Task> GetIntradayRecordsByMonthAsync(string s var jsonResponse = await response.Content.ReadAsStringAsync().ConfigureAwait(false); ThrowIfRejected(jsonResponse); - if (jsonResponse.Contains(Constants.ApiResponseLimitExceeded)) + if (IsRateLimited(jsonResponse)) { throw new FinanceNetException($"{Constants.ApiResponseLimitExceeded} for {symbol}"); } @@ -244,7 +244,7 @@ public async Task> GetForexRecordsAsync(string currency { var jsonResponse = await Helper.FetchJsonDocumentAsync(httpClient, _logger, url, ct).ConfigureAwait(false); ThrowIfRejected(jsonResponse); - if (jsonResponse.Contains(Constants.ApiResponseLimitExceeded)) + if (IsRateLimited(jsonResponse)) { throw new FinanceNetException($"{Constants.ApiResponseLimitExceeded} for {currency1} /{currency2}"); } @@ -262,16 +262,13 @@ public async Task> GetForexRecordsAsync(string currency } } - /// - /// 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. - /// + // Alpha Vantage refuses with HTTP 200 and a JSON message; a premium endpoint or a bad key will not change on retry. 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"); + "Alpha Vantage rejected the request: this is a premium endpoint and needs a premium plan"); } if (jsonResponse.Contains(Constants.ApiResponseApiKeyInvalid)) { @@ -279,4 +276,7 @@ private static void ThrowIfRejected(string jsonResponse) $"Alpha Vantage rejected the request: {Constants.ApiResponseApiKeyInvalid} or missing"); } } + + private static bool IsRateLimited(string jsonResponse) => + jsonResponse.Contains(Constants.ApiResponseRateLimit) || jsonResponse.Contains(Constants.ApiResponseLimitExceeded); } diff --git a/tests/Services/AlphaVantageRejectedRequestTests.cs b/tests/Services/AlphaVantageRejectedRequestTests.cs index 4a72fba..29c0841 100644 --- a/tests/Services/AlphaVantageRejectedRequestTests.cs +++ b/tests/Services/AlphaVantageRejectedRequestTests.cs @@ -96,18 +96,19 @@ public void InvalidApiKey_FailsFastWithoutRetrying(string name, Func(async () => await call(_service), name); Assert.That(exception.Message, Does.Contain("apikey is invalid"), name); + Assert.That(exception.InnerException, Is.Null, name); Assert.That(_requestCount, Is.EqualTo(1), $"{name}: an invalid-API-key refusal was retried"); } - [Test] - public void ApiLimitExceeded_IsStillRetried() + [TestCaseSource(nameof(Calls))] + public void RateLimit_IsStillRetried(string name, Func call) { - // 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.\"}"); + // The rate-limit notice advertises "premium endpoints" too, but clears on its own. + SetUpResponse("{\"Information\": \"Thank you for using Alpha Vantage! 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 (25 requests per day), raise the per-second burst limit, and instantly unlock all premium endpoints\"}"); - var exception = Assert.CatchAsync(async () => await _service.GetRecordsAsync("IBM", StartDate)); - Assert.That(exception, Is.Not.InstanceOf()); - Assert.That(_requestCount, Is.EqualTo(RetryCount + 1)); + var exception = Assert.CatchAsync(async () => await call(_service), name); + Assert.That(exception, Is.Not.InstanceOf(), name); + Assert.That(_requestCount, Is.EqualTo(RetryCount + 1), $"{name}: a rate-limit notice was not retried"); } [Test]