Skip to content

Commit 2d3848c

Browse files
committed
Leverage "Retry-After" usage to LUIS API calls
If a "Retry-After" HTTP response header is returned by LUIS, this change ensures that header is respected. Fixes #333
1 parent 4916f57 commit 2d3848c

10 files changed

Lines changed: 264 additions & 162 deletions

File tree

src/NLU.DevOps.Luis.Shared/ILuisTrainClient.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public interface ILuisTrainClient : IDisposable
4646
/// <param name="appId">LUIS app ID.</param>
4747
/// <param name="versionId">LUIS version ID.</param>
4848
/// <param name="cancellationToken">Cancellation token.</param>
49-
Task<IList<ModelTrainingInfo>> GetTrainingStatusAsync(string appId, string versionId, CancellationToken cancellationToken);
49+
Task<OperationResponse<IList<ModelTrainingInfo>>> GetTrainingStatusAsync(string appId, string versionId, CancellationToken cancellationToken);
5050

5151
/// <summary>
5252
/// Imports the LUIS app version.

src/NLU.DevOps.Luis.Shared/LuisNLUTrainClient.cs

Lines changed: 19 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -157,14 +157,6 @@ public void Dispose()
157157
this.LuisClient.Dispose();
158158
}
159159

160-
private static bool IsTransientStatusCode(HttpStatusCode statusCode)
161-
{
162-
return statusCode == HttpStatusCode.TooManyRequests
163-
|| (statusCode >= HttpStatusCode.InternalServerError
164-
&& statusCode != HttpStatusCode.HttpVersionNotSupported
165-
&& statusCode != HttpStatusCode.NotImplemented);
166-
}
167-
168160
private LuisApp CreateLuisApp(IEnumerable<ILabeledUtterance> utterances)
169161
{
170162
var luisApp = this.CreateLuisAppTemplate();
@@ -216,36 +208,31 @@ private async Task PollTrainingStatusAsync(CancellationToken cancellationToken)
216208
{
217209
while (true)
218210
{
219-
try
220-
{
221-
var trainingStatus = await this.LuisClient.GetTrainingStatusAsync(this.LuisAppId, this.LuisConfiguration.VersionId, cancellationToken).ConfigureAwait(false);
222-
var inProgress = trainingStatus
223-
.Select(modelInfo => modelInfo.Details.Status)
224-
.Any(status => status == "InProgress" || status == "Queued");
211+
var trainingStatus = await Retry.With(cancellationToken).OnTransientErrorResponseAsync(() =>
212+
this.LuisClient.GetTrainingStatusAsync(this.LuisAppId, this.LuisConfiguration.VersionId, cancellationToken))
213+
.ConfigureAwait(false);
225214

226-
if (!inProgress)
227-
{
228-
if (trainingStatus.Any(modelInfo => modelInfo.Details.Status == "Fail"))
229-
{
230-
var failureReasons = trainingStatus
231-
.Where(modelInfo => modelInfo.Details.Status == "Fail")
232-
.Select(modelInfo => $"- {modelInfo.Details.FailureReason}");
215+
var inProgress = trainingStatus.Value
216+
.Select(modelInfo => modelInfo.Details.Status)
217+
.Any(status => status == "InProgress" || status == "Queued");
233218

234-
throw new InvalidOperationException($"Failure occurred while training LUIS model:\n{string.Join('\n', failureReasons)}");
235-
}
219+
if (!inProgress)
220+
{
221+
if (trainingStatus.Value.Any(modelInfo => modelInfo.Details.Status == "Fail"))
222+
{
223+
var failureReasons = trainingStatus.Value
224+
.Where(modelInfo => modelInfo.Details.Status == "Fail")
225+
.Select(modelInfo => $"- {modelInfo.Details.FailureReason}");
236226

237-
break;
227+
throw new InvalidOperationException($"Failure occurred while training LUIS model:\n{string.Join('\n', failureReasons)}");
238228
}
239229

240-
Logger.LogTrace($"Training jobs not complete. Polling again.");
241-
await Task.Delay(TrainStatusDelay, cancellationToken).ConfigureAwait(false);
242-
}
243-
catch (ErrorResponseException ex)
244-
when (IsTransientStatusCode(ex.Response.StatusCode))
245-
{
246-
Logger.LogTrace("Received HTTP 429 result from LUIS. Retrying.");
247-
await Task.Delay(TrainStatusDelay, cancellationToken).ConfigureAwait(false);
230+
break;
248231
}
232+
233+
Logger.LogTrace($"Training jobs not complete. Polling again.");
234+
var delay = Retry.GetRetryAfterDelay(trainingStatus.RetryAfter, TrainStatusDelay);
235+
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
249236
}
250237
}
251238
}

src/NLU.DevOps.Luis.Shared/LuisTrainClient.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,10 @@ public Task DeleteVersionAsync(string appId, string versionId, CancellationToken
6161
return this.AuthoringClient.Versions.DeleteAsync(Guid.Parse(appId), versionId, cancellationToken);
6262
}
6363

64-
public Task<IList<ModelTrainingInfo>> GetTrainingStatusAsync(string appId, string versionId, CancellationToken cancellationToken)
64+
public async Task<OperationResponse<IList<ModelTrainingInfo>>> GetTrainingStatusAsync(string appId, string versionId, CancellationToken cancellationToken)
6565
{
66-
return this.AuthoringClient.Train.GetStatusAsync(Guid.Parse(appId), versionId, cancellationToken);
66+
var operationResponse = await this.AuthoringClient.Train.GetStatusWithHttpMessagesAsync(Guid.Parse(appId), versionId, cancellationToken: cancellationToken).ConfigureAwait(false);
67+
return OperationResponse.Create(operationResponse.Body, operationResponse.Response);
6768
}
6869

6970
public Task ImportVersionAsync(string appId, string versionId, LuisApp luisApp, CancellationToken cancellationToken)

src/NLU.DevOps.Luis.Shared/NLU.DevOps.Luis.Shared.projitems

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,8 @@
1818
<Compile Include="$(MSBuildThisFileDirectory)ILuisConfiguration.cs" />
1919
<Compile Include="$(MSBuildThisFileDirectory)TestLuisConfiguration.cs" />
2020
<Compile Include="$(MSBuildThisFileDirectory)JSONEntityWithRole.cs" />
21+
<Compile Include="$(MSBuildThisFileDirectory)Retry.cs" />
22+
<Compile Include="$(MSBuildThisFileDirectory)OperationResponse.Generic.cs" />
23+
<Compile Include="$(MSBuildThisFileDirectory)OperationResponse.cs" />
2124
</ItemGroup>
2225
</Project>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
namespace NLU.DevOps.Luis
5+
{
6+
/// <summary>
7+
/// Information about the batch test evaluation operation status.
8+
/// </summary>
9+
/// <typeparam name="T">Type of response value.</typeparam>
10+
public class OperationResponse<T>
11+
{
12+
internal OperationResponse(T value, string retryAfter)
13+
{
14+
this.Value = value;
15+
this.RetryAfter = retryAfter;
16+
}
17+
18+
/// <summary>
19+
/// Gets the response value.
20+
/// </summary>
21+
public T Value { get; }
22+
23+
/// <summary>
24+
/// Gets the HTTP 'Retry-After' header.
25+
/// </summary>
26+
public string RetryAfter { get; }
27+
}
28+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
namespace NLU.DevOps.Luis
5+
{
6+
using System.Linq;
7+
using System.Net.Http;
8+
9+
/// <summary>
10+
/// Factory methods for <see cref="OperationResponse{T}"/>.
11+
/// </summary>
12+
public static class OperationResponse
13+
{
14+
/// <summary>
15+
/// Creates an instance of <see cref="OperationResponse{T}"/>.
16+
/// </summary>
17+
/// <typeparam name="T">Type of response value.</typeparam>
18+
/// <param name="value">Response value.</param>
19+
/// <param name="response">HTTP response.</param>
20+
/// <returns>Instance of <see cref="OperationResponse{T}"/>.</returns>
21+
public static OperationResponse<T> Create<T>(T value, HttpResponseMessage response = default)
22+
{
23+
var retryAfter = response?.Headers?.GetValues(Retry.RetryAfterHeader).FirstOrDefault();
24+
return new OperationResponse<T>(value, retryAfter);
25+
}
26+
}
27+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
namespace NLU.DevOps.Luis
5+
{
6+
using System;
7+
using System.Globalization;
8+
using System.Linq;
9+
using System.Net;
10+
using System.Text.RegularExpressions;
11+
using System.Threading;
12+
using System.Threading.Tasks;
13+
using Microsoft.Azure.CognitiveServices.Language.LUIS.Authoring.Models;
14+
#if LUIS_V2
15+
using ErrorException = Microsoft.Azure.CognitiveServices.Language.LUIS.Runtime.Models.APIErrorException;
16+
#else
17+
using Microsoft.Azure.CognitiveServices.Language.LUIS.Runtime.Models;
18+
#endif
19+
20+
internal static class Retry
21+
{
22+
public const string RetryAfterHeader = "Retry-After";
23+
24+
private static readonly Regex RetryAfterSecondsRegex = new Regex(@"^\d+$");
25+
26+
private static TimeSpan DefaultTransientDelay { get; } = TimeSpan.FromMilliseconds(100);
27+
28+
public static TimeSpan GetRetryAfterDelay(string retryAfter, TimeSpan? defaultDelay = default)
29+
{
30+
if (retryAfter == null)
31+
{
32+
return defaultDelay ?? DefaultTransientDelay;
33+
}
34+
35+
if (RetryAfterSecondsRegex.IsMatch(retryAfter))
36+
{
37+
return TimeSpan.FromSeconds(int.Parse(retryAfter, CultureInfo.InvariantCulture));
38+
}
39+
40+
return DateTimeOffset.Parse(retryAfter, CultureInfo.InvariantCulture) - DateTimeOffset.Now;
41+
}
42+
43+
public static CancellationTokenHolder With(CancellationToken cancellationToken)
44+
{
45+
return new CancellationTokenHolder(cancellationToken);
46+
}
47+
48+
private static async Task<TResult> OnTransientExceptionAsync<TResult, TException>(
49+
Func<Task<TResult>> func,
50+
Func<TException, HttpStatusCode> statusCodeSelector,
51+
Func<TException, string> retryAfterDelaySelector = default,
52+
int retryCount = int.MaxValue,
53+
CancellationToken cancellationToken = default)
54+
where TException : Exception
55+
{
56+
var count = 0;
57+
while (count++ < retryCount)
58+
{
59+
cancellationToken.ThrowIfCancellationRequested();
60+
61+
try
62+
{
63+
return await func().ConfigureAwait(false);
64+
}
65+
catch (TException ex)
66+
when (count < retryCount && IsTransientStatusCode(statusCodeSelector(ex)))
67+
{
68+
var delay = GetRetryAfterDelay(retryAfterDelaySelector?.Invoke(ex));
69+
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
70+
}
71+
}
72+
73+
throw new InvalidOperationException("Exception will be rethrown before reaching this point.");
74+
}
75+
76+
private static bool IsTransientStatusCode(HttpStatusCode statusCode)
77+
{
78+
return statusCode == HttpStatusCode.TooManyRequests
79+
|| (statusCode >= HttpStatusCode.InternalServerError
80+
&& statusCode != HttpStatusCode.HttpVersionNotSupported
81+
&& statusCode != HttpStatusCode.NotImplemented);
82+
}
83+
84+
public class CancellationTokenHolder
85+
{
86+
public CancellationTokenHolder(CancellationToken cancellationToken)
87+
{
88+
this.CancellationToken = cancellationToken;
89+
}
90+
91+
private CancellationToken CancellationToken { get; }
92+
93+
public Task<T> OnTransientErrorAsync<T>(Func<Task<T>> func)
94+
{
95+
return OnTransientExceptionAsync(
96+
func,
97+
(ErrorException ex) => ex.Response.StatusCode,
98+
(ErrorException ex) => ex.Response.Headers?[RetryAfterHeader]?.FirstOrDefault(),
99+
cancellationToken: this.CancellationToken);
100+
}
101+
102+
public Task<T> OnTransientErrorResponseAsync<T>(Func<Task<T>> func)
103+
{
104+
return OnTransientExceptionAsync(
105+
func,
106+
(ErrorResponseException ex) => ex.Response.StatusCode,
107+
(ErrorResponseException ex) => ex.Response.Headers?[RetryAfterHeader]?.FirstOrDefault(),
108+
cancellationToken: this.CancellationToken);
109+
}
110+
111+
public Task<T> OnTransientWebExceptionAsync<T>(Func<Task<T>> func)
112+
{
113+
return OnTransientExceptionAsync(
114+
func,
115+
(WebException ex) => (ex.Response as HttpWebResponse)?.StatusCode ?? default,
116+
(WebException ex) => (ex.Response as HttpWebResponse)?.Headers?[RetryAfterHeader],
117+
cancellationToken: this.CancellationToken);
118+
}
119+
}
120+
}
121+
}

src/NLU.DevOps.Luis.Tests.Shared/LuisNLUTrainClientTests.cs

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -215,13 +215,14 @@ public static async Task TrainingStatusDelayBetweenPolling()
215215
It.Is<string>(appId => appId == builder.AppId),
216216
It.IsAny<string>(),
217217
It.IsAny<CancellationToken>()))
218-
.Returns(() => Task.FromResult<IList<ModelTrainingInfo>>(new[]
219-
{
220-
new ModelTrainingInfo
218+
.Returns(() => Task.FromResult(
219+
OperationResponse.Create<IList<ModelTrainingInfo>>(new[]
221220
{
222-
Details = new ModelTrainingDetails { Status = statusArray[count++] }
223-
}
224-
}))
221+
new ModelTrainingInfo
222+
{
223+
Details = new ModelTrainingDetails { Status = statusArray[count++] }
224+
}
225+
})))
225226
.Callback(() => timestamps[count - 1] = DateTimeOffset.Now);
226227

227228
using (var luis = builder.Build())
@@ -251,13 +252,14 @@ public static void TrainingFailedThrowsInvalidOperation()
251252
It.Is<string>(appId => appId == builder.AppId),
252253
It.IsAny<string>(),
253254
It.IsAny<CancellationToken>()))
254-
.Returns(() => Task.FromResult<IList<ModelTrainingInfo>>(new[]
255-
{
256-
new ModelTrainingInfo
255+
.Returns(() => Task.FromResult(
256+
OperationResponse.Create<IList<ModelTrainingInfo>>(new[]
257257
{
258-
Details = new ModelTrainingDetails { Status = "Fail", FailureReason = failureReason }
259-
}
260-
}));
258+
new ModelTrainingInfo
259+
{
260+
Details = new ModelTrainingDetails { Status = "Fail", FailureReason = failureReason }
261+
}
262+
})));
261263

262264
using (var luis = builder.Build())
263265
{
@@ -377,8 +379,9 @@ private class LuisNLUTrainClientBuilder
377379
public LuisNLUTrainClient Build()
378380
{
379381
this.MockLuisTrainClient.SetReturnsDefault(
380-
Task.FromResult<IList<ModelTrainingInfo>>(
381-
Array.Empty<ModelTrainingInfo>()));
382+
Task.FromResult(
383+
OperationResponse.Create<IList<ModelTrainingInfo>>(
384+
Array.Empty<ModelTrainingInfo>())));
382385

383386
var luisConfiguration = new LuisConfiguration(new ConfigurationBuilder()
384387
.AddInMemoryCollection(new Dictionary<string, string>

0 commit comments

Comments
 (0)