Skip to content

Commit 8fa536a

Browse files
authored
Merge pull request #252 from siemens/feat/RetryStreatagyForAPI_10_02_25
Retry API requests to SW360 and FOSSology Urls.
2 parents 061f121 + 8029fae commit 8fa536a

15 files changed

+319
-29
lines changed

doc/UsageDoc/CA_UsageDocument.md

+2
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,9 @@ Currently LTA support is not provided for SBOM, hence until that is implemented
226226
![image.png](../usagedocimg/output.PNG)
227227
228228
Resulted `output.json` file will be having the list of installed packages and the same file will be used as an input to `Continuous clearing tool - Bom creator` as an argument(`--packagefilepath`). The remaining process is same as other project types.
229+
### **API Calls Retry Strategy**
229230

231+
The retry strategy is implemented using the Polly library to handle transient errors such as HTTP request exceptions, task cancellations, and specific HTTP status codes (5xx server errors and 408 Request Timeout). The policy is configured to retry operations up to three times with increasing intervals between attempts (5, 10, and 30 seconds). This approach ensures that network communication is more resilient and reliable by automatically retrying failed requests due to transient issues.
230232
### **Configuring the Continuous Clearing Tool**
231233

232234
Arguments can be provided to the tool in two ways :
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// --------------------------------------------------------------------------------------------------------------------
2+
// SPDX-FileCopyrightText: 2025 Siemens AG
3+
//
4+
// SPDX-License-Identifier: MIT
5+
// --------------------------------------------------------------------------------------------------------------------
6+
7+
using log4net;
8+
using Moq.Protected;
9+
using Moq;
10+
using System.Net;
11+
12+
namespace LCT.APICommunications.UTest
13+
{
14+
public class RetryHttpClientHandlerUTest
15+
{
16+
private Mock<ILog> _mockLogger;
17+
18+
[SetUp]
19+
public void SetUp()
20+
{
21+
// Mock the logger
22+
_mockLogger = new Mock<ILog>();
23+
}
24+
[Test]
25+
public async Task SendAsync_ShouldRetry_OnTransientErrors()
26+
{
27+
// Arrange
28+
var handlerMock = new Mock<HttpMessageHandler>(MockBehavior.Strict);
29+
handlerMock
30+
.Protected()
31+
.SetupSequence<Task<HttpResponseMessage>>(
32+
"SendAsync",
33+
ItExpr.IsAny<HttpRequestMessage>(),
34+
ItExpr.IsAny<CancellationToken>())
35+
.ThrowsAsync(new HttpRequestException())
36+
.ThrowsAsync(new TaskCanceledException())
37+
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
38+
39+
var retryHandler = new RetryHttpClientHandler
40+
{
41+
InnerHandler = handlerMock.Object
42+
};
43+
44+
var httpClient = new HttpClient(retryHandler);
45+
46+
// Act
47+
var response = await httpClient.GetAsync("http://test.com");
48+
49+
// Assert
50+
Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
51+
handlerMock.Protected().Verify(
52+
"SendAsync",
53+
Times.Exactly(3), // 2 retries + 1 initial call
54+
ItExpr.IsAny<HttpRequestMessage>(),
55+
ItExpr.IsAny<CancellationToken>());
56+
}
57+
58+
[Test]
59+
public async Task SendAsync_ShouldNotRetry_OnNonTransientErrors()
60+
{
61+
// Arrange
62+
var handlerMock = new Mock<HttpMessageHandler>(MockBehavior.Strict);
63+
handlerMock
64+
.Protected()
65+
.Setup<Task<HttpResponseMessage>>(
66+
"SendAsync",
67+
ItExpr.IsAny<HttpRequestMessage>(),
68+
ItExpr.IsAny<CancellationToken>())
69+
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.BadRequest));
70+
71+
var retryHandler = new RetryHttpClientHandler
72+
{
73+
InnerHandler = handlerMock.Object
74+
};
75+
76+
var httpClient = new HttpClient(retryHandler);
77+
78+
// Act
79+
var response = await httpClient.GetAsync("http://test.com");
80+
81+
// Assert
82+
Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode);
83+
handlerMock.Protected().Verify(
84+
"SendAsync",
85+
Times.Once(), // No retries
86+
ItExpr.IsAny<HttpRequestMessage>(),
87+
ItExpr.IsAny<CancellationToken>());
88+
}
89+
90+
[Test]
91+
public async Task SendAsync_ShouldLogRetryAttempts()
92+
{
93+
// Arrange
94+
var handlerMock = new Mock<HttpMessageHandler>(MockBehavior.Strict);
95+
handlerMock
96+
.Protected()
97+
.SetupSequence<Task<HttpResponseMessage>>(
98+
"SendAsync",
99+
ItExpr.IsAny<HttpRequestMessage>(),
100+
ItExpr.IsAny<CancellationToken>())
101+
.ThrowsAsync(new HttpRequestException())
102+
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
103+
104+
105+
var retryHandler = new RetryHttpClientHandler()
106+
{
107+
InnerHandler = handlerMock.Object
108+
};
109+
110+
var httpClient = new HttpClient(retryHandler);
111+
112+
// Act
113+
var response = await httpClient.GetAsync("http://test.com");
114+
115+
// Assert
116+
Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
117+
}
118+
[Test]
119+
public async Task ExecuteWithRetryAsync_ShouldCompleteSuccessfully_WhenActionSucceedsAfterRetry()
120+
{
121+
// Arrange
122+
var attempts = 0;
123+
var action = new Func<Task>(() =>
124+
{
125+
attempts++;
126+
if (attempts < ApiConstant.APIRetryIntervals.Count)
127+
{
128+
throw new WebException("Temporary error", WebExceptionStatus.Timeout);
129+
}
130+
return Task.CompletedTask; // Successfully completes after retries
131+
});
132+
133+
// Act
134+
await RetryHttpClientHandler.ExecuteWithRetryAsync(action);
135+
136+
// Assert
137+
Assert.AreEqual(ApiConstant.APIRetryIntervals.Count, attempts, "Action should have been attempted the expected number of times.");
138+
}
139+
140+
[Test]
141+
public async Task ExecuteWithRetryAsync_ShouldNotRetry_WhenNoWebExceptionIsThrown()
142+
{
143+
// Arrange
144+
var actionExecuted = false;
145+
var action = new Func<Task>(() =>
146+
{
147+
actionExecuted = true;
148+
return Task.CompletedTask;
149+
});
150+
151+
// Act
152+
await RetryHttpClientHandler.ExecuteWithRetryAsync(action);
153+
154+
// Assert
155+
Assert.IsTrue(actionExecuted, "Action should have been executed.");
156+
_mockLogger.Verify(logger => logger.Debug(It.IsAny<string>()), Times.Never, "Retry should not occur if there is no exception.");
157+
}
158+
159+
}
160+
}

src/LCT.APICommunications/ApiConstant.cs

+4-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
// SPDX-License-Identifier: MIT
55
// --------------------------------------------------------------------------------------------------------------------
66

7+
using System.Collections.Generic;
8+
79
namespace LCT.APICommunications
810
{
911
/// <summary>
@@ -72,6 +74,7 @@ public static class ApiConstant
7274
public const string InvalidArtifactory = "Invalid artifactory";
7375
public const string PackageNotFound = "Package Not Found";
7476
public const string ArtifactoryRepoName = "ArtifactoryRepoName";
75-
public const string JfrogArtifactoryApiSearchAql = $"/api/search/aql";
77+
public const string JfrogArtifactoryApiSearchAql = $"/api/search/aql";
78+
public static readonly List<int> APIRetryIntervals = [5, 10, 30]; // in seconds
7679
}
7780
}

src/LCT.APICommunications/DebianJfrogAPICommunication.cs

+5-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ public DebianJfrogAPICommunication(string repoDomainName, string srcrepoName, Ar
3030

3131
private static HttpClient GetHttpClient(ArtifactoryCredentials credentials)
3232
{
33-
HttpClient httpClient = new HttpClient();
33+
var handler = new RetryHttpClientHandler()
34+
{
35+
InnerHandler = new HttpClientHandler()
36+
};
37+
var httpClient = new HttpClient(handler);
3438
TimeSpan timeOutInSec = TimeSpan.FromSeconds(TimeoutInSec);
3539
httpClient.Timeout = timeOutInSec;
3640
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", credentials.Token);

src/LCT.APICommunications/JfrogApicommunication.cs

+5-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ protected JfrogApicommunication(string repoDomainName, string srcrepoName, Artif
3232

3333
private static HttpClient GetHttpClient(ArtifactoryCredentials credentials)
3434
{
35-
HttpClient httpClient = new HttpClient();
35+
var handler = new RetryHttpClientHandler()
36+
{
37+
InnerHandler = new HttpClientHandler()
38+
};
39+
var httpClient = new HttpClient(handler);
3640
TimeSpan timeOutInSec = TimeSpan.FromSeconds(TimeoutInSec);
3741
httpClient.Timeout = timeOutInSec;
3842
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", credentials.Token);

src/LCT.APICommunications/JfrogAqlApiCommunication.cs

+5-1
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,11 @@ public async Task<HttpResponseMessage> GetPackageInfo(ComponentsToArtifactory co
116116

117117
private static HttpClient GetHttpClient(ArtifactoryCredentials credentials)
118118
{
119-
HttpClient httpClient = new HttpClient();
119+
var handler = new RetryHttpClientHandler()
120+
{
121+
InnerHandler = new HttpClientHandler()
122+
};
123+
var httpClient = new HttpClient(handler);
120124
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", credentials.Token);
121125
return httpClient;
122126
}

src/LCT.APICommunications/LCT.APICommunications.csproj

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
<PackageReference Include="Microsoft.PowerShell.Commands.Utility" Version="7.0.3" />
2424
<PackageReference Include="Microsoft.Web.Administration" Version="11.1.0" />
2525
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
26+
<PackageReference Include="Polly" Version="8.5.2" />
2627
<PackageReference Include="System.Configuration.ConfigurationManager" Version="4.7.0" />
2728
<PackageReference Include="System.Drawing.Common" Version="8.0.6" />
2829
<PackageReference Include="System.Management.Automation" Version="7.0.3" />

src/LCT.APICommunications/MavenJfrogApiCommunication.cs

+5-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ public MavenJfrogApiCommunication(string repoDomainName, string srcrepoName, Art
2121
}
2222
private static HttpClient GetHttpClient(ArtifactoryCredentials credentials)
2323
{
24-
HttpClient httpClient = new HttpClient();
24+
var handler = new RetryHttpClientHandler()
25+
{
26+
InnerHandler = new HttpClientHandler()
27+
};
28+
var httpClient = new HttpClient(handler);
2529
TimeSpan timeOutInSec = TimeSpan.FromSeconds(TimeoutInSec);
2630
httpClient.Timeout = timeOutInSec;
2731
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", credentials.Token);

src/LCT.APICommunications/NpmJfrogAPICommunication.cs

+5-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ public NpmJfrogApiCommunication(string repoDomainName, string srcrepoName, Artif
2828

2929
private static HttpClient GetHttpClient(ArtifactoryCredentials credentials)
3030
{
31-
HttpClient httpClient = new HttpClient();
31+
var handler = new RetryHttpClientHandler()
32+
{
33+
InnerHandler = new HttpClientHandler()
34+
};
35+
var httpClient = new HttpClient(handler);
3236
TimeSpan timeOutInSec = TimeSpan.FromSeconds(TimeoutInSec);
3337
httpClient.Timeout = timeOutInSec;
3438
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", credentials.Token);

src/LCT.APICommunications/NugetJfrogAPICommunication.cs

+5-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ public NugetJfrogApiCommunication(string repoDomainName, string srcrepoName, Art
2828

2929
private static HttpClient GetHttpClient(ArtifactoryCredentials credentials)
3030
{
31-
HttpClient httpClient = new HttpClient();
31+
var handler = new RetryHttpClientHandler()
32+
{
33+
InnerHandler = new HttpClientHandler()
34+
};
35+
var httpClient = new HttpClient(handler);
3236
TimeSpan timeOutInSec = TimeSpan.FromSeconds(TimeoutInSec);
3337
httpClient.Timeout = timeOutInSec;
3438
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", credentials.Token);

src/LCT.APICommunications/PythonJfrogAPICommunication.cs

+5-2
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,11 @@ public PythonJfrogApiCommunication(string repoDomainName, string srcrepoName, Ar
2424
}
2525
private static HttpClient GetHttpClient(ArtifactoryCredentials credentials)
2626
{
27-
HttpClient httpClient = new HttpClient();
28-
27+
var handler = new RetryHttpClientHandler()
28+
{
29+
InnerHandler = new HttpClientHandler()
30+
};
31+
var httpClient = new HttpClient(handler);
2932
TimeSpan timeOutInSec = TimeSpan.FromSeconds(TimeoutInSec);
3033
httpClient.Timeout = timeOutInSec;
3134
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", credentials.Token);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// --------------------------------------------------------------------------------------------------------------------
2+
// SPDX-FileCopyrightText: 2025 Siemens AG
3+
//
4+
// SPDX-License-Identifier: MIT
5+
// --------------------------------------------------------------------------------------------------------------------
6+
using log4net;
7+
using Polly;
8+
using System;
9+
using System.Linq;
10+
using System.Net;
11+
using System.Net.Http;
12+
using System.Reflection;
13+
using System.Threading.Tasks;
14+
15+
namespace LCT.APICommunications
16+
{
17+
public class RetryHttpClientHandler : DelegatingHandler
18+
{
19+
private readonly AsyncPolicy<HttpResponseMessage> _retryPolicy;
20+
private static readonly ILog Logger = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType);
21+
private bool _initialRetryLogged = false;
22+
public RetryHttpClientHandler()
23+
{
24+
// Define the retry policy (retry on 5xx, 408, and transient errors)
25+
_retryPolicy = Policy
26+
.Handle<HttpRequestException>()
27+
.Or<TaskCanceledException>()
28+
.OrResult<HttpResponseMessage>(r =>
29+
(r.StatusCode == HttpStatusCode.RequestTimeout
30+
|| (int)r.StatusCode >= 500)
31+
&& r.StatusCode != HttpStatusCode.Unauthorized
32+
&& r.StatusCode != HttpStatusCode.Forbidden)
33+
.WaitAndRetryAsync(ApiConstant.APIRetryIntervals.Count,
34+
GetRetryInterval,
35+
onRetry: (outcome, timespan, attempt, context) =>
36+
{
37+
Logger.Debug($"Retry attempt {attempt} due to: {(outcome.Exception != null ? outcome.Exception.Message : $"{outcome.Result.StatusCode}")}");
38+
if (!_initialRetryLogged && context["LogWarnings"] as bool? != false)
39+
{
40+
Logger.Warn($"Retry attempt triggered due to: {(outcome.Exception != null ? outcome.Exception.Message : $"{outcome.Result.StatusCode}")}");
41+
}
42+
context["RetryAttempt"] = attempt;
43+
_initialRetryLogged = true;
44+
45+
});
46+
}
47+
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken)
48+
{
49+
var context = new Context
50+
{
51+
["LogWarnings"] = !request.Headers.TryGetValues("LogWarnings", out var logWarningsValues) || !bool.TryParse(logWarningsValues.FirstOrDefault(), out var logWarnings) || logWarnings
52+
};
53+
54+
var response = await _retryPolicy.ExecuteAsync(async (ctx) =>
55+
{
56+
return await base.SendAsync(request, cancellationToken); // Pass the request to the next handler (HttpClient)
57+
}, context);
58+
59+
if (_initialRetryLogged)
60+
{
61+
var attempt = context.ContainsKey("RetryAttempt") ? context["RetryAttempt"] : 0;
62+
Logger.Debug($"Retry attempt successful after {attempt} attempts.");
63+
_initialRetryLogged = false;
64+
}
65+
66+
return response;
67+
}
68+
public static async Task ExecuteWithRetryAsync(Func<Task> action)
69+
{
70+
var retryPolicy = Policy
71+
.Handle<WebException>()
72+
.WaitAndRetryAsync(ApiConstant.APIRetryIntervals.Count,
73+
GetRetryInterval,
74+
onRetry: (exception, timespan, attempt, context) =>
75+
{
76+
Logger.Debug($"Retry attempt {attempt} due to: {exception?.Message ?? "No exception"}");
77+
});
78+
79+
await retryPolicy.ExecuteAsync(action);
80+
}
81+
private static TimeSpan GetRetryInterval(int attempt)
82+
{
83+
if (attempt >= 1 && attempt <= ApiConstant.APIRetryIntervals.Count)
84+
return TimeSpan.FromSeconds(ApiConstant.APIRetryIntervals[attempt - 1]);
85+
return TimeSpan.Zero; // Default if out of range
86+
}
87+
88+
}
89+
}

0 commit comments

Comments
 (0)