Skip to content

Commit 1806337

Browse files
committed
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
1 parent 7612ad5 commit 1806337

5 files changed

Lines changed: 157 additions & 94 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: 56 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,23 @@ 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"`
7678
}
7779

80+
type ResponseRuleType string
81+
82+
const (
83+
ResponseRuleTypeInvalid ResponseRuleType = "invalid"
84+
ResponseRuleTypeSetup ResponseRuleType = "setup"
85+
)
86+
7887
// Rule configuration defines what kind of values are expected to exist in HTTP response, like status code or message in the body.
7988
type Rule struct {
80-
MatchingHTTPCodes []int `hcl:"http_codes,optional"`
81-
MatchingBodyPart string `hcl:"body,optional"`
89+
Type ResponseRuleType `hcl:"type,optional"`
90+
MatchingHTTPCodes []int `hcl:"http_codes,optional"`
91+
MatchingBodyPart string `hcl:"body,optional"`
8292
}
8393

8494
// Helper struct storing response HTTP status code and parsed response body
@@ -289,8 +299,7 @@ func defaultConfiguration() *HTTPTargetConfig {
289299

290300
ContentType: "application/json",
291301
ResponseRules: &ResponseRules{
292-
Invalid: []Rule{},
293-
SetupError: []Rule{},
302+
Rules: []Rule{},
294303
},
295304
IncludeTimingHeaders: false,
296305
RejectionThresholdInMillis: 150,
@@ -420,42 +429,53 @@ func (ht *HTTPTarget) Write(messages []*models.Message) (*models.TargetWriteResu
420429
})
421430
}
422431

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-
})
432+
// Find first matching rule in order
433+
var matchedRule *Rule
434+
for _, rule := range ht.responseRules.Rules {
435+
if ruleMatches(response, rule) {
436+
matchedRule = &rule
437+
break
430438
}
431-
432-
invalid = append(invalid, goodMsgs...)
433-
continue
434439
}
435440

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-
}
441+
if matchedRule != nil {
442+
switch matchedRule.Type {
443+
case ResponseRuleTypeInvalid:
444+
for _, msg := range goodMsgs {
445+
msg.SetError(&models.ApiError{
446+
StatusCode: resp.Status,
447+
ResponseBody: response.Body,
448+
SafeMessage: "Invalid error",
449+
})
450+
}
451+
invalid = append(invalid, goodMsgs...)
452+
continue
453+
454+
case ResponseRuleTypeSetup:
455+
hitSetupError = true
456+
var errorDetails error
457+
if matchedRule.MatchingBodyPart != "" {
458+
errorDetails = fmt.Errorf("got setup error, response status: '%s' with error details: '%s'", resp.Status, matchedRule.MatchingBodyPart)
459+
} else {
460+
errorDetails = fmt.Errorf("got setup error, response status: '%s'", resp.Status)
461+
}
445462

446-
for _, msg := range goodMsgs {
447-
msg.SetError(&models.ApiError{
448-
StatusCode: resp.Status,
449-
ResponseBody: response.Body,
450-
SafeMessage: "Setup error",
451-
})
463+
for _, msg := range goodMsgs {
464+
msg.SetError(&models.ApiError{
465+
StatusCode: resp.Status,
466+
ResponseBody: response.Body,
467+
SafeMessage: "Setup error",
468+
})
469+
}
470+
errResult = multierror.Append(errResult, errorDetails)
471+
failed = append(failed, goodMsgs...)
452472
}
453-
454473
} else {
455-
errorDetails = fmt.Errorf("got transient error, response status: '%s'", resp.Status)
474+
// No rule matched - transient error
475+
errorDetails := fmt.Errorf("got transient error, response status: '%s'", resp.Status)
476+
errResult = multierror.Append(errResult, errorDetails)
477+
failed = append(failed, goodMsgs...)
456478
}
457-
errResult = multierror.Append(errResult, errorDetails)
458-
failed = append(failed, goodMsgs...)
459479
}
460480
}
461481

@@ -467,15 +487,6 @@ func (ht *HTTPTarget) Write(messages []*models.Message) (*models.TargetWriteResu
467487
return models.NewTargetWriteResult(sent, failed, oversized, invalid), errResult
468488
}
469489

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-
479490
func ruleMatches(res response, rule Rule) bool {
480491
codeMatch := httpStatusMatches(res.Status, rule.MatchingHTTPCodes)
481492
if rule.MatchingBodyPart != "" {
@@ -485,12 +496,7 @@ func ruleMatches(res response, rule Rule) bool {
485496
}
486497

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

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

pkg/target/http_response_rules_test.go

Lines changed: 82 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,58 +21,116 @@ func TestHTTP_Rules_StatusMatch(t *testing.T) {
2121
assert := assert.New(t)
2222

2323
response := response{Status: 500, Body: "Invalid field 'attribute'"}
24-
rules := []Rule{
25-
{MatchingHTTPCodes: []int{500, 503}},
26-
}
24+
rule := Rule{MatchingHTTPCodes: []int{500, 503}}
2725

28-
matchingRule := findMatchingRule(response, rules)
29-
assert.Equal(&rules[0], matchingRule)
26+
matches := ruleMatches(response, rule)
27+
assert.True(matches)
3028
}
3129

3230
func TestHTTP_Rules_FullBodyMatch(t *testing.T) {
3331
assert := assert.New(t)
3432

3533
response := response{Status: 500, Body: "Invalid field 'attribute'"}
36-
rules := []Rule{
37-
{MatchingHTTPCodes: []int{500, 503}, MatchingBodyPart: "Invalid field 'attribute'"},
38-
}
34+
rule := Rule{MatchingHTTPCodes: []int{500, 503}, MatchingBodyPart: "Invalid field 'attribute'"}
3935

40-
matchingRule := findMatchingRule(response, rules)
41-
assert.Equal(&rules[0], matchingRule)
36+
matches := ruleMatches(response, rule)
37+
assert.True(matches)
4238
}
4339

4440
func TestHTTP_Rules_PartialBodyMatch(t *testing.T) {
4541
assert := assert.New(t)
4642

4743
response := response{Status: 500, Body: "Invalid field 'attribute'"}
48-
rules := []Rule{
49-
{MatchingHTTPCodes: []int{500, 503}, MatchingBodyPart: "Invalid field"},
50-
}
44+
rule := Rule{MatchingHTTPCodes: []int{500, 503}, MatchingBodyPart: "Invalid field"}
5145

52-
matchingRule := findMatchingRule(response, rules)
53-
assert.Equal(&rules[0], matchingRule)
46+
matches := ruleMatches(response, rule)
47+
assert.True(matches)
5448
}
5549

5650
func TestHTTP_Rules_StatusMatch_NoBodyMatch(t *testing.T) {
5751
assert := assert.New(t)
5852

5953
response := response{Status: 500, Body: "Invalid field 'attribute'"}
60-
rules := []Rule{
61-
{MatchingHTTPCodes: []int{500, 503}, MatchingBodyPart: "Invalid field 'events'"},
62-
}
54+
rule := Rule{MatchingHTTPCodes: []int{500, 503}, MatchingBodyPart: "Invalid field 'events'"}
6355

64-
matchingRule := findMatchingRule(response, rules)
65-
assert.Nil(matchingRule)
56+
matches := ruleMatches(response, rule)
57+
assert.False(matches)
6658
}
6759

6860
func TestHTTP_Rules_NoStatusMatch_BodyMatch(t *testing.T) {
6961
assert := assert.New(t)
7062

7163
response := response{Status: 500, Body: "Invalid field 'attribute'"}
72-
rules := []Rule{
73-
{MatchingHTTPCodes: []int{503}, MatchingBodyPart: "Invalid field"},
64+
rule := Rule{MatchingHTTPCodes: []int{503}, MatchingBodyPart: "Invalid field"}
65+
66+
matches := ruleMatches(response, rule)
67+
assert.False(matches)
68+
}
69+
70+
func TestHTTP_ResponseRules_OrderedEvaluation(t *testing.T) {
71+
assert := assert.New(t)
72+
73+
// Create HTTP target with ordered rules (setup first, then invalid)
74+
responseRules := &ResponseRules{
75+
Rules: []Rule{
76+
{
77+
Type: ResponseRuleTypeSetup,
78+
MatchingHTTPCodes: []int{500},
79+
MatchingBodyPart: "database",
80+
},
81+
{
82+
Type: ResponseRuleTypeInvalid,
83+
MatchingHTTPCodes: []int{500},
84+
MatchingBodyPart: "validation",
85+
},
86+
{
87+
Type: ResponseRuleTypeSetup,
88+
MatchingHTTPCodes: []int{500},
89+
}, // no body requirement
90+
},
91+
}
92+
93+
ht := &HTTPTarget{responseRules: responseRules}
94+
95+
// Test that setup rule with "database" body matches first
96+
resp := response{Status: 500, Body: "database connection failed"}
97+
var matchedRule *Rule
98+
for _, rule := range ht.responseRules.Rules {
99+
if ruleMatches(resp, rule) {
100+
matchedRule = &rule
101+
break
102+
}
103+
}
104+
105+
assert.NotNil(matchedRule)
106+
assert.Equal(ResponseRuleTypeSetup, matchedRule.Type)
107+
assert.Equal("database", matchedRule.MatchingBodyPart)
108+
109+
// Test that invalid rule with "validation" body matches when database doesn't
110+
matchedRule = nil
111+
resp = response{Status: 500, Body: "validation error occurred"}
112+
for _, rule := range ht.responseRules.Rules {
113+
if ruleMatches(resp, rule) {
114+
matchedRule = &rule
115+
break
116+
}
117+
}
118+
119+
assert.NotNil(matchedRule)
120+
assert.Equal(ResponseRuleTypeInvalid, matchedRule.Type)
121+
assert.Equal("validation", matchedRule.MatchingBodyPart)
122+
123+
// Test that third setup rule matches when no body specified
124+
resp = response{Status: 500}
125+
matchedRule = nil
126+
for _, rule := range ht.responseRules.Rules {
127+
if ruleMatches(resp, rule) {
128+
matchedRule = &rule
129+
break
130+
}
74131
}
75132

76-
matchingRule := findMatchingRule(response, rules)
77-
assert.Nil(matchingRule)
133+
assert.NotNil(matchedRule)
134+
assert.Equal(ResponseRuleTypeSetup, matchedRule.Type)
135+
assert.Equal("", matchedRule.MatchingBodyPart)
78136
}

pkg/target/http_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -766,8 +766,8 @@ func TestHTTP_Write_Invalid(t *testing.T) {
766766
defer server.Close()
767767

768768
responseRules := ResponseRules{
769-
Invalid: []Rule{
770-
{MatchingHTTPCodes: []int{400, 401}, MatchingBodyPart: "Invalid value for field 'attribute'"},
769+
Rules: []Rule{
770+
{Type: ResponseRuleTypeInvalid, MatchingHTTPCodes: []int{400, 401}, MatchingBodyPart: "Invalid value for field 'attribute'"},
771771
},
772772
}
773773

@@ -801,8 +801,8 @@ func TestHTTP_Write_Setup(t *testing.T) {
801801
defer server.Close()
802802

803803
responseRules := ResponseRules{
804-
SetupError: []Rule{
805-
{MatchingHTTPCodes: []int{401}, MatchingBodyPart: "Invalid token"},
804+
Rules: []Rule{
805+
{Type: ResponseRuleTypeSetup, MatchingHTTPCodes: []int{401}, MatchingBodyPart: "Invalid token"},
806806
},
807807
}
808808

0 commit comments

Comments
 (0)