Skip to content

Commit 5190846

Browse files
authored
feat: ordered evaluation of response_rules (#466)
* breaking change - response_rules are now evaluated in the order they defined in the config and they distinguish between invalid/setup based on the rule's type attribute, rather than setting invalid/setup block within response_rules configuration block * update hcl configs in e2e tests * add rules type validation
1 parent 7612ad5 commit 5190846

9 files changed

Lines changed: 241 additions & 99 deletions

File tree

assets/docs/configuration/targets/http-full-example.hcl

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -78,24 +78,23 @@ target {
7878
rejection_threshold_in_millis = 100
7979

8080
# Optional HTTP response rules which are used to match HTTP response code/body and categorize it as either invalid data or target setup error.
81-
# For example, we can have 2 invalid + 1 setup error rules:
81+
# Rules are evaluated in order as declared. First matching rule determines the error type.
8282
response_rules {
83-
# This one is a match when...
84-
invalid {
85-
# ...HTTP statuses match...
83+
# Invalid rule for purchase field validation error
84+
rule {
85+
type = "invalid"
8686
http_codes = [400]
87-
# AND this string exists in a response body
8887
body = "Invalid value for 'purchase' field"
8988
}
90-
# If no match yet, we can check the next one...
91-
invalid {
92-
# again 400 status...
89+
# Invalid rule for attributes field validation error
90+
rule {
91+
type = "invalid"
9392
http_codes = [400]
94-
# BUT we expect different error message in the response body
9593
body = "Invalid value for 'attributes' field"
9694
}
97-
# Same for 'setup' rules..
98-
setup {
95+
# Setup rule for authentication errors
96+
rule {
97+
type = "setup"
9998
http_codes = [401, 403]
10099
}
101100
}

config/component_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,7 @@ func TestCreateTargetComponentHCL(t *testing.T) {
9292
IncludeTimingHeaders: false,
9393
RejectionThresholdInMillis: 150,
9494
ResponseRules: &target.ResponseRules{
95-
Invalid: []target.Rule{},
96-
SetupError: []target.Rule{},
95+
Rules: []target.Rule{},
9796
},
9897
},
9998
},
@@ -125,18 +124,19 @@ func TestCreateTargetComponentHCL(t *testing.T) {
125124
IncludeTimingHeaders: true,
126125
RejectionThresholdInMillis: 100,
127126
ResponseRules: &target.ResponseRules{
128-
Invalid: []target.Rule{
127+
Rules: []target.Rule{
129128
{
129+
Type: target.ResponseRuleTypeInvalid,
130130
MatchingHTTPCodes: []int{400},
131131
MatchingBodyPart: "Invalid value for 'purchase' field",
132132
},
133133
{
134+
Type: target.ResponseRuleTypeInvalid,
134135
MatchingHTTPCodes: []int{400},
135136
MatchingBodyPart: "Invalid value for 'attributes' field",
136137
},
137-
},
138-
SetupError: []target.Rule{
139138
{
139+
Type: target.ResponseRuleTypeSetup,
140140
MatchingHTTPCodes: []int{401, 403},
141141
},
142142
},

pkg/target/http.go

Lines changed: 74 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"io"
2020
"net/http"
2121
"os"
22+
"slices"
2223
"strconv"
2324
"strings"
2425
"text/template"
@@ -71,14 +72,32 @@ type HTTPTargetConfig struct {
7172

7273
// ResponseRules is part of HTTP target configuration. It provides rules how HTTP respones should be handled. Response can be categerized as 'invalid' (bad data), as setup error or (if none of the rules matches) as a transient error.
7374
type ResponseRules struct {
74-
Invalid []Rule `hcl:"invalid,block"`
75-
SetupError []Rule `hcl:"setup,block"`
75+
Rules []Rule `hcl:"rule,block"`
76+
// Invalid []Rule `hcl:"invalid,block"`
77+
// SetupError []Rule `hcl:"setup,block"`
78+
}
79+
80+
type ResponseRuleType string
81+
82+
const (
83+
ResponseRuleTypeInvalid ResponseRuleType = "invalid"
84+
ResponseRuleTypeSetup ResponseRuleType = "setup"
85+
)
86+
87+
func isValidResponseRuleType(ruleType ResponseRuleType) bool {
88+
switch ruleType {
89+
case ResponseRuleTypeInvalid, ResponseRuleTypeSetup:
90+
return true
91+
default:
92+
return false
93+
}
7694
}
7795

7896
// Rule configuration defines what kind of values are expected to exist in HTTP response, like status code or message in the body.
7997
type Rule struct {
80-
MatchingHTTPCodes []int `hcl:"http_codes,optional"`
81-
MatchingBodyPart string `hcl:"body,optional"`
98+
Type ResponseRuleType `hcl:"type,optional"`
99+
MatchingHTTPCodes []int `hcl:"http_codes,optional"`
100+
MatchingBodyPart string `hcl:"body,optional"`
82101
}
83102

84103
// Helper struct storing response HTTP status code and parsed response body
@@ -244,6 +263,15 @@ func HTTPTargetConfigFunction(c *HTTPTargetConfig) (*HTTPTarget, error) {
244263
return nil, errors.New("target error: Byte limit must be larger than template size")
245264
}
246265

266+
// validating response rules from config
267+
if c.ResponseRules != nil {
268+
for _, rule := range c.ResponseRules.Rules {
269+
if !isValidResponseRuleType(rule.Type) {
270+
return nil, fmt.Errorf("target error: Invalid response rule type '%s'. Valid types are: 'invalid', 'setup'", rule.Type)
271+
}
272+
}
273+
}
274+
247275
return &HTTPTarget{
248276
client: client,
249277
httpURL: c.URL,
@@ -289,8 +317,7 @@ func defaultConfiguration() *HTTPTargetConfig {
289317

290318
ContentType: "application/json",
291319
ResponseRules: &ResponseRules{
292-
Invalid: []Rule{},
293-
SetupError: []Rule{},
320+
Rules: []Rule{},
294321
},
295322
IncludeTimingHeaders: false,
296323
RejectionThresholdInMillis: 150,
@@ -420,42 +447,53 @@ func (ht *HTTPTarget) Write(messages []*models.Message) (*models.TargetWriteResu
420447
})
421448
}
422449

423-
if matchedRule := findMatchingRule(response, ht.responseRules.Invalid); matchedRule != nil {
424-
for _, msg := range goodMsgs {
425-
msg.SetError(&models.ApiError{
426-
StatusCode: resp.Status,
427-
ResponseBody: response.Body,
428-
SafeMessage: "Invalid error",
429-
})
450+
// Find first matching rule in order
451+
var matchedRule *Rule
452+
for _, rule := range ht.responseRules.Rules {
453+
if ruleMatches(response, rule) {
454+
matchedRule = &rule
455+
break
430456
}
431-
432-
invalid = append(invalid, goodMsgs...)
433-
continue
434457
}
435458

436-
var errorDetails error
437-
if rule := findMatchingRule(response, ht.responseRules.SetupError); rule != nil {
438-
hitSetupError = true
439-
440-
if rule.MatchingBodyPart != "" {
441-
errorDetails = fmt.Errorf("got setup error, response status: '%s' with error details: '%s'", resp.Status, rule.MatchingBodyPart)
442-
} else {
443-
errorDetails = fmt.Errorf("got setup error, response status: '%s'", resp.Status)
444-
}
459+
if matchedRule != nil {
460+
switch matchedRule.Type {
461+
case ResponseRuleTypeInvalid:
462+
for _, msg := range goodMsgs {
463+
msg.SetError(&models.ApiError{
464+
StatusCode: resp.Status,
465+
ResponseBody: response.Body,
466+
SafeMessage: "Invalid error",
467+
})
468+
}
469+
invalid = append(invalid, goodMsgs...)
470+
continue
471+
472+
case ResponseRuleTypeSetup:
473+
hitSetupError = true
474+
var errorDetails error
475+
if matchedRule.MatchingBodyPart != "" {
476+
errorDetails = fmt.Errorf("got setup error, response status: '%s' with error details: '%s'", resp.Status, matchedRule.MatchingBodyPart)
477+
} else {
478+
errorDetails = fmt.Errorf("got setup error, response status: '%s'", resp.Status)
479+
}
445480

446-
for _, msg := range goodMsgs {
447-
msg.SetError(&models.ApiError{
448-
StatusCode: resp.Status,
449-
ResponseBody: response.Body,
450-
SafeMessage: "Setup error",
451-
})
481+
for _, msg := range goodMsgs {
482+
msg.SetError(&models.ApiError{
483+
StatusCode: resp.Status,
484+
ResponseBody: response.Body,
485+
SafeMessage: "Setup error",
486+
})
487+
}
488+
errResult = multierror.Append(errResult, errorDetails)
489+
failed = append(failed, goodMsgs...)
452490
}
453-
454491
} else {
455-
errorDetails = fmt.Errorf("got transient error, response status: '%s'", resp.Status)
492+
// No rule matched - transient error
493+
errorDetails := fmt.Errorf("got transient error, response status: '%s'", resp.Status)
494+
errResult = multierror.Append(errResult, errorDetails)
495+
failed = append(failed, goodMsgs...)
456496
}
457-
errResult = multierror.Append(errResult, errorDetails)
458-
failed = append(failed, goodMsgs...)
459497
}
460498
}
461499

@@ -467,15 +505,6 @@ func (ht *HTTPTarget) Write(messages []*models.Message) (*models.TargetWriteResu
467505
return models.NewTargetWriteResult(sent, failed, oversized, invalid), errResult
468506
}
469507

470-
func findMatchingRule(res response, rules []Rule) *Rule {
471-
for _, rule := range rules {
472-
if ruleMatches(res, rule) {
473-
return &rule
474-
}
475-
}
476-
return nil
477-
}
478-
479508
func ruleMatches(res response, rule Rule) bool {
480509
codeMatch := httpStatusMatches(res.Status, rule.MatchingHTTPCodes)
481510
if rule.MatchingBodyPart != "" {
@@ -485,12 +514,7 @@ func ruleMatches(res response, rule Rule) bool {
485514
}
486515

487516
func httpStatusMatches(actual int, expectedCodes []int) bool {
488-
for _, expected := range expectedCodes {
489-
if expected == actual {
490-
return true
491-
}
492-
}
493-
return false
517+
return slices.Contains(expectedCodes, actual)
494518
}
495519

496520
func responseBodyMatches(actual string, bodyPattern string) bool {

0 commit comments

Comments
 (0)