Skip to content

Commit ae5bf12

Browse files
authored
Merge pull request #240 from simplify9/hamza/fix/badresult-retry-matchers
Fix bad-result retries never matching
2 parents a279f62 + d7affec commit ae5bf12

7 files changed

Lines changed: 113 additions & 13 deletions

File tree

SW.Bitween.Api/Resources/RetryPolicies/Create.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public Create(BitweenDbContext dbContext, RequestContext requestContext)
2020
public async Task<object> Handle(RetryPolicyCreate model)
2121
{
2222
_requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member);
23+
RetryGroupValidation.EnsureCanFire(model.Groups);
2324

2425
var entity = new RetryPolicy
2526
{
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using SW.Bitween.Model;
4+
using SW.PrimitiveTypes;
5+
6+
namespace SW.Bitween.Resources.RetryPolicies;
7+
8+
/// <summary>
9+
/// Rejects retry groups that could never fire. The evaluator skips such groups silently
10+
/// (matchers must support the result type being evaluated — see
11+
/// <see cref="Matcher.Supports"/>), which reads as "retries just don't work", so the
12+
/// misconfiguration is caught at write time instead.
13+
/// </summary>
14+
public static class RetryGroupValidation
15+
{
16+
public static void EnsureCanFire(IEnumerable<RetryGroup> groups)
17+
{
18+
foreach (var group in groups ?? [])
19+
{
20+
if ((group.AppliesTo?.Count ?? 0) == 0)
21+
throw new SWValidationException("RETRY_GROUP_NO_RESULT_TYPE",
22+
$"Group '{group.Name}' applies to no result type, so it would never be evaluated. " +
23+
"Select Error, Bad result, or both.");
24+
25+
if ((group.Matchers?.Count ?? 0) == 0)
26+
throw new SWValidationException("RETRY_GROUP_NO_MATCHERS",
27+
$"Group '{group.Name}' has no matchers, so it would never match a failure. " +
28+
"Add at least one matcher.");
29+
30+
foreach (var resultType in group.AppliesTo)
31+
if (!group.Matchers.Any(m => m.Supports(resultType)))
32+
throw new SWValidationException("RETRY_GROUP_INCOMPATIBLE_MATCHERS",
33+
$"Group '{group.Name}' applies to {resultType} but none of its matchers can be " +
34+
$"evaluated against {resultType} content. {SupportedMatchersFor(resultType)}");
35+
}
36+
}
37+
38+
private static string SupportedMatchersFor(XchangeResultType resultType) => resultType switch
39+
{
40+
XchangeResultType.Error => "Error supports Contains, Regex and Exception type matchers.",
41+
XchangeResultType.BadResult => "Bad result supports Contains, Regex and JSON path matchers.",
42+
_ => "Successful results are never retried."
43+
};
44+
}

SW.Bitween.Api/Resources/RetryPolicies/Update.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public Update(BitweenDbContext dbContext, RequestContext requestContext)
2020
public async Task<object> Handle(int key, RetryPolicyUpdate model)
2121
{
2222
_requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member);
23+
RetryGroupValidation.EnsureCanFire(model.Groups);
2324

2425
var entity = await _dbContext.FindAsync<RetryPolicy>(key);
2526
entity.Name = model.Name;

SW.Bitween.Api/Resources/Subscriptions/Update.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using System.Threading.Tasks;
1212
using Microsoft.AspNetCore.Http;
1313
using SW.Bitween.Domain.Accounts;
14+
using SW.Bitween.Resources.RetryPolicies;
1415

1516
namespace SW.Bitween.Resources.Subscriptions
1617
{
@@ -58,6 +59,9 @@ public async Task<object> Handle(int key, SubscriptionUpdate model)
5859
throw new SWValidationException("RETRY_POLICY_NOT_FOUND",
5960
$"Retry policy {model.RetryPolicyId} was not found.");
6061

62+
if (model.CustomRetryPolicy != null)
63+
RetryGroupValidation.EnsureCanFire(model.CustomRetryPolicy.Groups);
64+
6165
entity.SetRetryPolicy(model.RetryPolicyId, model.CustomRetryPolicy);
6266

6367
trail.SetAfter(entity);

SW.Bitween.Sdk/Model/AutoRetry/Matcher.cs

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public enum JsonPathOp
3434
/// </summary>
3535
/// <remarks>
3636
/// For <see cref="XchangeResultType.Error"/> groups the content is the exception stack-trace text.
37-
/// For <see cref="XchangeResultType.BadResult"/> groups the content is the raw JSON response string.
37+
/// For <see cref="XchangeResultType.BadResult"/> groups the content is the raw response body.
3838
/// Matcher implementations are serialised polymorphically via <c>System.Text.Json</c>.
3939
/// </remarks>
4040
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
@@ -44,8 +44,13 @@ public enum JsonPathOp
4444
[JsonDerivedType(typeof(JsonPathMatcher), typeDiscriminator: "jsonPath")]
4545
public abstract class Matcher
4646
{
47-
/// <summary>The result type this matcher operates on.</summary>
48-
public abstract XchangeResultType ResultType { get; }
47+
/// <summary>
48+
/// Returns <c>true</c> when this matcher can be evaluated against
49+
/// <paramref name="resultType"/> content. The evaluator skips incompatible matchers,
50+
/// so a group whose matchers all return <c>false</c> here can never fire for that
51+
/// result type.
52+
/// </summary>
53+
public abstract bool Supports(XchangeResultType resultType);
4954

5055
/// <summary>
5156
/// Returns <c>true</c> when <paramref name="content"/> satisfies this matcher's condition.
@@ -54,16 +59,17 @@ public abstract class Matcher
5459
public abstract bool IsMatch(string content);
5560
}
5661

57-
// ── Error matchers ────────────────────────────────────────────────────────────
62+
// ── Text matchers (Error and BadResult) ───────────────────────────────────────
5863

5964
/// <summary>
60-
/// Matches when the exception text contains a literal substring.
61-
/// Applies to <see cref="XchangeResultType.Error"/> content.
65+
/// Matches when the failure text contains a literal substring — the exception text for
66+
/// <see cref="XchangeResultType.Error"/>, the response body for <see cref="XchangeResultType.BadResult"/>.
6267
/// </summary>
6368
public class ContainsMatcher : Matcher
6469
{
6570
/// <inheritdoc/>
66-
public override XchangeResultType ResultType => XchangeResultType.Error;
71+
public override bool Supports(XchangeResultType resultType) =>
72+
resultType is XchangeResultType.Error or XchangeResultType.BadResult;
6773

6874
/// <summary>The substring to search for.</summary>
6975
public required string Value { get; init; }
@@ -78,13 +84,14 @@ public override bool IsMatch(string content) =>
7884
}
7985

8086
/// <summary>
81-
/// Matches when the exception text satisfies a regular expression.
82-
/// Applies to <see cref="XchangeResultType.Error"/> content.
87+
/// Matches when the failure text satisfies a regular expression — the exception text for
88+
/// <see cref="XchangeResultType.Error"/>, the response body for <see cref="XchangeResultType.BadResult"/>.
8389
/// </summary>
8490
public class RegexMatcher : Matcher
8591
{
8692
/// <inheritdoc/>
87-
public override XchangeResultType ResultType => XchangeResultType.Error;
93+
public override bool Supports(XchangeResultType resultType) =>
94+
resultType is XchangeResultType.Error or XchangeResultType.BadResult;
8895

8996
/// <summary>.NET-compatible regular expression pattern.</summary>
9097
public required string Pattern { get; init; }
@@ -106,6 +113,8 @@ public class RegexMatcher : Matcher
106113
public override bool IsMatch(string content) => Compiled.IsMatch(content);
107114
}
108115

116+
// ── Error-only matcher ────────────────────────────────────────────────────────
117+
109118
/// <summary>
110119
/// Matches when the exception text mentions a specific .NET exception type name,
111120
/// scanning the entire stack-trace including inner exceptions.
@@ -118,7 +127,8 @@ public class RegexMatcher : Matcher
118127
public class ExceptionTypeMatcher : Matcher
119128
{
120129
/// <inheritdoc/>
121-
public override XchangeResultType ResultType => XchangeResultType.Error;
130+
public override bool Supports(XchangeResultType resultType) =>
131+
resultType == XchangeResultType.Error;
122132

123133
/// <summary>
124134
/// Fully-qualified or short exception type name, e.g. <c>"System.TimeoutException"</c>
@@ -163,7 +173,8 @@ public override bool IsMatch(string content)
163173
public class JsonPathMatcher : Matcher
164174
{
165175
/// <inheritdoc/>
166-
public override XchangeResultType ResultType => XchangeResultType.BadResult;
176+
public override bool Supports(XchangeResultType resultType) =>
177+
resultType == XchangeResultType.BadResult;
167178

168179
/// <summary>JSONPath expression, e.g. <c>"$.error.code"</c> or <c>"$.lines[0].status"</c>.</summary>
169180
public required string Path { get; init; }

SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ public RetryDecision Evaluate(
108108
.Where(g => g.Enabled && g.AppliesTo.Contains(resultType))
109109
.OrderBy(g => g.Priority))
110110
{
111-
var compatibleMatchers = group.Matchers.Where(m => m.ResultType == resultType);
111+
var compatibleMatchers = group.Matchers.Where(m => m.Supports(resultType));
112112
if (compatibleMatchers.Any(m => m.IsMatch(content)))
113113
return group;
114114
}

SW.Bitween.UnitTests/RetryPolicyEvaluatorTests.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,45 @@ public void Evaluator_WrongResultType_GroupSkipped()
246246
Assert.IsFalse(decision.ShouldRetry);
247247
}
248248

249+
[TestMethod]
250+
public void Evaluator_ContainsMatcher_MatchesBadResultBody()
251+
{
252+
var policy = PolicyWith(BadResultGroup("bad", new ContainsMatcher { Value = "INSUFFICIENT_STOCK" }));
253+
var ev = new RetryPolicyEvaluator(policy);
254+
var decision = ev.Evaluate(XchangeResultType.BadResult, "{\"code\":\"INSUFFICIENT_STOCK\"}", 0);
255+
Assert.IsTrue(decision.ShouldRetry);
256+
Assert.AreEqual("bad", decision.MatchedGroupName);
257+
}
258+
259+
[TestMethod]
260+
public void Evaluator_ContainsMatcher_MatchesNonJsonBadResultBody()
261+
{
262+
// A 4xx body need not be JSON — text matchers are the only way to reach these.
263+
var policy = PolicyWith(BadResultGroup("bad", new ContainsMatcher { Value = "rate limit" }));
264+
var ev = new RetryPolicyEvaluator(policy);
265+
var decision = ev.Evaluate(XchangeResultType.BadResult, "<html><body>Rate limit exceeded</body></html>", 0);
266+
Assert.IsTrue(decision.ShouldRetry);
267+
}
268+
269+
[TestMethod]
270+
public void Evaluator_RegexMatcher_MatchesBadResultBody()
271+
{
272+
var policy = PolicyWith(BadResultGroup("bad", new RegexMatcher { Pattern = @"""status"":\s*""FAILED""" }));
273+
var ev = new RetryPolicyEvaluator(policy);
274+
var decision = ev.Evaluate(XchangeResultType.BadResult, "{\"status\": \"FAILED\"}", 0);
275+
Assert.IsTrue(decision.ShouldRetry);
276+
}
277+
278+
[TestMethod]
279+
public void Evaluator_ExceptionTypeMatcher_SkippedForBadResult()
280+
{
281+
// Exception type names are meaningless against a response body — stays Error-only.
282+
var policy = PolicyWith(BadResultGroup("bad", new ExceptionTypeMatcher { Value = "System.TimeoutException" }));
283+
var ev = new RetryPolicyEvaluator(policy);
284+
var decision = ev.Evaluate(XchangeResultType.BadResult, "System.TimeoutException in body", 0);
285+
Assert.IsFalse(decision.ShouldRetry);
286+
}
287+
249288
// ─── Evaluator: priority ordering ───────────────────────────────────────────
250289

251290
[TestMethod]

0 commit comments

Comments
 (0)