Skip to content

Commit 019ec23

Browse files
committed
chore: improve cache invalidation, fix namespaces in the Caching package
1 parent ae65974 commit 019ec23

6 files changed

Lines changed: 143 additions & 35 deletions

File tree

src/Reliable.HttpClient.Caching/DefaultSimpleCacheKeyGenerator.cs renamed to src/Reliable.HttpClient.Caching/Abstractions/DefaultSimpleCacheKeyGenerator.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
namespace Reliable.HttpClient.Caching;
1+
namespace Reliable.HttpClient.Caching.Abstractions;
22

33
/// <summary>
44
/// Default cache key generator implementation

src/Reliable.HttpClient.Caching/ISimpleCacheKeyGenerator.cs renamed to src/Reliable.HttpClient.Caching/Abstractions/ISimpleCacheKeyGenerator.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
namespace Reliable.HttpClient.Caching;
1+
namespace Reliable.HttpClient.Caching.Abstractions;
22

33
/// <summary>
44
/// Simple cache key generator for universal caching

src/Reliable.HttpClient.Caching/CachedHttpClient.cs

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,32 +34,33 @@ public async Task<TResponse> SendAsync(
3434
Func<HttpResponseMessage, Task<TResponse>> responseHandler,
3535
CancellationToken cancellationToken = default)
3636
{
37+
string? cacheKey = null;
38+
3739
// Check if this request should be cached
38-
if (!ShouldCacheRequest(request))
40+
if (ShouldCacheRequest(request))
3941
{
40-
_logger.LogDebug("Request not cacheable: {Method} {Uri}", request.Method, request.RequestUri);
41-
HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
42-
return await responseHandler(response).ConfigureAwait(false);
43-
}
42+
cacheKey = _options.KeyGenerator.GenerateKey(request);
4443

45-
// Generate cache key
46-
var cacheKey = _options.KeyGenerator.GenerateKey(request);
44+
TResponse? cachedResponse = await _cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false);
45+
if (cachedResponse is not null)
46+
{
47+
_logger.LogDebug("Returning cached response for: {Method} {Uri}", request.Method, request.RequestUri);
48+
return cachedResponse;
49+
}
4750

48-
// Try to get from cache first
49-
TResponse? cachedResponse = await _cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false);
50-
if (cachedResponse is not null)
51+
_logger.LogDebug("Cache miss, executing request: {Method} {Uri}", request.Method, request.RequestUri);
52+
}
53+
else
5154
{
52-
_logger.LogDebug("Returning cached response for: {Method} {Uri}", request.Method, request.RequestUri);
53-
return cachedResponse;
55+
_logger.LogDebug("Request not cacheable: {Method} {Uri}", request.Method, request.RequestUri);
5456
}
5557

56-
// Execute request
57-
_logger.LogDebug("Cache miss, executing request: {Method} {Uri}", request.Method, request.RequestUri);
58+
// Execute request (single code path for both cacheable and non-cacheable requests)
5859
HttpResponseMessage httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
59-
TResponse? result = await responseHandler(httpResponse).ConfigureAwait(false);
60+
TResponse result = await responseHandler(httpResponse).ConfigureAwait(false);
6061

6162
// Cache the response if it should be cached
62-
if (ShouldCacheResponse(request, httpResponse))
63+
if (cacheKey is not null && ShouldCacheResponse(request, httpResponse))
6364
{
6465
TimeSpan expiry = _options.GetExpiry(request, httpResponse);
6566
await _cache.SetAsync(cacheKey, result, expiry, cancellationToken).ConfigureAwait(false);

src/Reliable.HttpClient.Caching/Extensions/HttpClientWithCacheExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
using Microsoft.Extensions.Caching.Memory;
33
using Microsoft.Extensions.Logging;
44

5+
using Reliable.HttpClient.Caching.Abstractions;
6+
57
namespace Reliable.HttpClient.Caching.Extensions;
68

79
/// <summary>

src/Reliable.HttpClient.Caching/HttpClientWithCache.cs

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
using Microsoft.Extensions.Caching.Memory;
44
using Microsoft.Extensions.Logging;
55

6+
using Reliable.HttpClient.Caching.Abstractions;
7+
68
namespace Reliable.HttpClient.Caching;
79

810
/// <summary>
@@ -71,11 +73,13 @@ public async Task<TResponse> PostAsync<TRequest, TResponse>(
7173
TRequest content,
7274
CancellationToken cancellationToken = default) where TResponse : class
7375
{
74-
// POST requests are not cached and may invalidate related cache entries
76+
HttpResponseMessage response = await _httpClient.PostAsJsonAsync(requestUri, content, cancellationToken).ConfigureAwait(false);
77+
TResponse result = await _responseHandler.HandleAsync<TResponse>(response, cancellationToken).ConfigureAwait(false);
78+
79+
// Invalidate cache only after successful response handling
7580
await InvalidateRelatedCacheAsync(requestUri).ConfigureAwait(false);
7681

77-
HttpResponseMessage response = await _httpClient.PostAsJsonAsync(requestUri, content, cancellationToken).ConfigureAwait(false);
78-
return await _responseHandler.HandleAsync<TResponse>(response, cancellationToken).ConfigureAwait(false);
82+
return result;
7983
}
8084

8185
/// <inheritdoc />
@@ -93,23 +97,27 @@ public async Task<TResponse> PutAsync<TRequest, TResponse>(
9397
TRequest content,
9498
CancellationToken cancellationToken = default) where TResponse : class
9599
{
96-
// PUT requests are not cached and may invalidate related cache entries
100+
HttpResponseMessage response = await _httpClient.PutAsJsonAsync(requestUri, content, cancellationToken).ConfigureAwait(false);
101+
TResponse result = await _responseHandler.HandleAsync<TResponse>(response, cancellationToken).ConfigureAwait(false);
102+
103+
// Invalidate cache only after successful response handling
97104
await InvalidateRelatedCacheAsync(requestUri).ConfigureAwait(false);
98105

99-
HttpResponseMessage response = await _httpClient.PutAsJsonAsync(requestUri, content, cancellationToken).ConfigureAwait(false);
100-
return await _responseHandler.HandleAsync<TResponse>(response, cancellationToken).ConfigureAwait(false);
106+
return result;
101107
}
102108

103109
/// <inheritdoc />
104110
public async Task<TResponse> DeleteAsync<TResponse>(
105111
string requestUri,
106112
CancellationToken cancellationToken = default) where TResponse : class
107113
{
108-
// DELETE requests are not cached and may invalidate related cache entries
114+
HttpResponseMessage response = await _httpClient.DeleteAsync(requestUri, cancellationToken).ConfigureAwait(false);
115+
TResponse result = await _responseHandler.HandleAsync<TResponse>(response, cancellationToken).ConfigureAwait(false);
116+
117+
// Invalidate cache only after successful response handling
109118
await InvalidateRelatedCacheAsync(requestUri).ConfigureAwait(false);
110119

111-
HttpResponseMessage response = await _httpClient.DeleteAsync(requestUri, cancellationToken).ConfigureAwait(false);
112-
return await _responseHandler.HandleAsync<TResponse>(response, cancellationToken).ConfigureAwait(false);
120+
return result;
113121
}
114122

115123
/// <inheritdoc />
@@ -159,14 +167,20 @@ async Task<HttpResponseMessage> IHttpClientAdapter.PostAsync<TRequest>(
159167
string requestUri, TRequest content, CancellationToken cancellationToken)
160168
{
161169
HttpResponseMessage response = await _httpClient.PostAsJsonAsync(requestUri, content, cancellationToken).ConfigureAwait(false);
170+
171+
// Invalidate cache only after successful HTTP request (before response handler to maintain adapter contract)
162172
await InvalidateRelatedCacheAsync(requestUri).ConfigureAwait(false);
173+
163174
return response;
164175
}
165176

166177
async Task<HttpResponseMessage> IHttpClientAdapter.DeleteAsync(string requestUri, CancellationToken cancellationToken)
167178
{
168179
HttpResponseMessage response = await _httpClient.DeleteAsync(requestUri, cancellationToken).ConfigureAwait(false);
180+
181+
// Invalidate cache only after successful HTTP request (before response handler to maintain adapter contract)
169182
await InvalidateRelatedCacheAsync(requestUri).ConfigureAwait(false);
183+
170184
return response;
171185
}
172186
}

tests/Reliable.HttpClient.Caching.Tests/HttpClientWithCacheTests.cs

Lines changed: 99 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
using System.Text;
77
using System.Text.Json;
88
using Xunit;
9-
using Reliable.HttpClient.Caching;
9+
10+
using Reliable.HttpClient.Caching.Abstractions;
1011

1112
namespace Reliable.HttpClient.Caching.Tests;
1213

@@ -28,7 +29,7 @@ public HttpClientWithCacheTests()
2829
_mockHttpMessageHandler = new Mock<HttpMessageHandler>();
2930
_httpClient = new System.Net.Http.HttpClient(_mockHttpMessageHandler.Object)
3031
{
31-
BaseAddress = new Uri("https://api.test.com")
32+
BaseAddress = new Uri("https://api.test.com"),
3233
};
3334
_cache = new MemoryCache(new MemoryCacheOptions());
3435
_mockResponseHandler = new Mock<IHttpResponseHandler>();
@@ -52,7 +53,7 @@ public async Task GetAsync_FirstCall_MakesHttpRequestAndCachesResult()
5253
var expectedResponse = new TestResponse { Id = 1, Name = "Test" };
5354
var httpResponse = new HttpResponseMessage(HttpStatusCode.OK)
5455
{
55-
Content = new StringContent(JsonSerializer.Serialize(expectedResponse))
56+
Content = new StringContent(JsonSerializer.Serialize(expectedResponse)),
5657
};
5758

5859
_mockCacheKeyGenerator
@@ -72,14 +73,14 @@ public async Task GetAsync_FirstCall_MakesHttpRequestAndCachesResult()
7273
.ReturnsAsync(expectedResponse);
7374

7475
// Act
75-
var result = await _httpClientWithCache.GetAsync<TestResponse>(requestUri);
76+
TestResponse result = await _httpClientWithCache.GetAsync<TestResponse>(requestUri);
7677

7778
// Assert
7879
Assert.Equal(expectedResponse, result);
7980
_mockResponseHandler.Verify(x => x.HandleAsync<TestResponse>(httpResponse, It.IsAny<CancellationToken>()), Times.Once);
8081

8182
// Verify result is cached
82-
var cachedResult = _cache.Get<TestResponse>(cacheKey);
83+
TestResponse? cachedResult = _cache.Get<TestResponse>(cacheKey);
8384
Assert.Equal(expectedResponse, cachedResult);
8485
}
8586

@@ -99,7 +100,7 @@ public async Task GetAsync_SecondCall_ReturnsCachedResult()
99100
_cache.Set(cacheKey, cachedResponse);
100101

101102
// Act
102-
var result = await _httpClientWithCache.GetAsync<TestResponse>(requestUri);
103+
TestResponse result = await _httpClientWithCache.GetAsync<TestResponse>(requestUri);
103104

104105
// Assert
105106
Assert.Equal(cachedResponse, result);
@@ -129,7 +130,7 @@ public async Task PostAsync_InvalidatesRelatedCache()
129130

130131
var httpResponse = new HttpResponseMessage(HttpStatusCode.OK)
131132
{
132-
Content = new StringContent(JsonSerializer.Serialize(postResponse))
133+
Content = new StringContent(JsonSerializer.Serialize(postResponse)),
133134
};
134135

135136
_mockHttpMessageHandler
@@ -145,7 +146,7 @@ public async Task PostAsync_InvalidatesRelatedCache()
145146
.ReturnsAsync(postResponse);
146147

147148
// Act
148-
var result = await _httpClientWithCache.PostAsync<object, TestResponse>(postUri, postRequest);
149+
TestResponse result = await _httpClientWithCache.PostAsync<object, TestResponse>(postUri, postRequest);
149150

150151
// Assert
151152
Assert.Equal(postResponse, result);
@@ -179,6 +180,96 @@ public async Task ClearCacheAsync_LogsClearRequest()
179180
Assert.True(true);
180181
}
181182

183+
[Fact]
184+
public async Task PostAsync_ResponseHandlerThrows_CacheRemainsValid()
185+
{
186+
// Arrange
187+
const string cacheKey = "TestResponse_/api/test";
188+
const string requestUri = "/api/test";
189+
var cachedData = new TestResponse { Id = 1, Name = "Cached" };
190+
var requestData = new { Name = "New Data" };
191+
192+
// Pre-populate cache with valid data
193+
_cache.Set(cacheKey, cachedData, TimeSpan.FromMinutes(5));
194+
_mockCacheKeyGenerator.Setup(x => x.GenerateKey("TestResponse", requestUri))
195+
.Returns(cacheKey);
196+
197+
// Setup HTTP client to return successful response
198+
var responseContent = JsonSerializer.Serialize(new TestResponse { Id = 2, Name = "Updated" });
199+
var httpResponse = new HttpResponseMessage(HttpStatusCode.OK)
200+
{
201+
Content = new StringContent(responseContent, Encoding.UTF8, "application/json")
202+
};
203+
204+
_mockHttpMessageHandler.Protected()
205+
.Setup<Task<HttpResponseMessage>>(
206+
"SendAsync",
207+
ItExpr.Is<HttpRequestMessage>(req => req.Method == HttpMethod.Post),
208+
ItExpr.IsAny<CancellationToken>())
209+
.ReturnsAsync(httpResponse);
210+
211+
// Setup response handler to throw exception
212+
_mockResponseHandler.Setup(x => x.HandleAsync<TestResponse>(It.IsAny<HttpResponseMessage>(), It.IsAny<CancellationToken>()))
213+
.ThrowsAsync(new InvalidOperationException("Response handler failed"));
214+
215+
// Act & Assert
216+
await Assert.ThrowsAsync<InvalidOperationException>(
217+
() => _httpClientWithCache.PostAsync<object, TestResponse>(requestUri, requestData));
218+
219+
// Verify that cached data is still available (cache was not invalidated due to failure)
220+
var cacheExists = _cache.TryGetValue(cacheKey, out TestResponse? stillCachedResult);
221+
Assert.True(cacheExists);
222+
Assert.NotNull(stillCachedResult);
223+
Assert.Equal(1, stillCachedResult.Id);
224+
Assert.Equal("Cached", stillCachedResult.Name);
225+
}
226+
227+
[Fact]
228+
public async Task PostAsync_SuccessfulHandling_InvalidatesCache()
229+
{
230+
// Arrange
231+
const string cacheKey = "TestResponse_/api/test";
232+
const string requestUri = "/api/test";
233+
var cachedData = new TestResponse { Id = 1, Name = "Cached" };
234+
var requestData = new { Name = "New Data" };
235+
var responseData = new TestResponse { Id = 2, Name = "Updated" };
236+
237+
// Pre-populate cache with valid data
238+
_cache.Set(cacheKey, cachedData, TimeSpan.FromMinutes(5));
239+
_mockCacheKeyGenerator.Setup(x => x.GenerateKey("TestResponse", requestUri))
240+
.Returns(cacheKey);
241+
242+
// Setup HTTP client to return successful response
243+
var responseContent = JsonSerializer.Serialize(responseData);
244+
var httpResponse = new HttpResponseMessage(HttpStatusCode.OK)
245+
{
246+
Content = new StringContent(responseContent, Encoding.UTF8, "application/json"),
247+
};
248+
249+
_mockHttpMessageHandler.Protected()
250+
.Setup<Task<HttpResponseMessage>>(
251+
"SendAsync",
252+
ItExpr.Is<HttpRequestMessage>(req => req.Method == HttpMethod.Post),
253+
ItExpr.IsAny<CancellationToken>())
254+
.ReturnsAsync(httpResponse);
255+
256+
// Setup response handler to succeed
257+
_mockResponseHandler.Setup(x => x.HandleAsync<TestResponse>(It.IsAny<HttpResponseMessage>(), It.IsAny<CancellationToken>()))
258+
.ReturnsAsync(responseData);
259+
260+
// Act
261+
var result = await _httpClientWithCache.PostAsync<object, TestResponse>(requestUri, requestData);
262+
263+
// Assert
264+
Assert.NotNull(result);
265+
Assert.Equal(2, result.Id);
266+
Assert.Equal("Updated", result.Name);
267+
268+
// Cache invalidation is attempted (though MemoryCache doesn't support pattern-based invalidation)
269+
// We verify the behavior through the successful completion of the operation
270+
Assert.True(true); // Placeholder for cache invalidation verification
271+
}
272+
182273
private class TestResponse
183274
{
184275
public int Id { get; set; }

0 commit comments

Comments
 (0)