Skip to content

Commit de6d14c

Browse files
s35560claude
andcommitted
chore(feature): improve error message and logs when variation change error occurs
Deleting a variation that is still referenced returned a generic error that did not explain the cause. References inside the flag surfaced as "InvalidArgumentNotMatchFormatError" with field "variation", and references from other flags surfaced as a bare "FailedPreconditionError". Neither carried the variation or the flag holding the reference, and no server-side log was written at all. Split the single ErrVariationInUse sentinel into six errors, one per cause, each with its own message key so the console can explain what to fix: - VariationInUseByOffVariationError - VariationInUseByDefaultStrategyError - VariationInUseByTargetingRuleError - VariationInUseByIndividualTargetingError - VariationInUseByPrerequisiteError - VariationInUseByFeatureFlagRuleError The cross-flag errors embed the referencing flag id and name so the console can name it, and their message spells out the whole relationship for the logs ("variation X of feature A is used as a prerequisite by feature B"). Also add logVariationInUseError so the rejected update is recorded with the environment, the flag, the variations being deleted and the reference. Validation failures were previously not logged anywhere. Note that the gRPC code for same-flag references changes from InvalidArgument to FailedPrecondition, matching the code already used for cross-flag references. Closes #1794 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 5a82f0f commit de6d14c

15 files changed

Lines changed: 522 additions & 63 deletions

File tree

pkg/api/api/grpc_status.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,18 @@ func convertErrorReason(errorType pkgErr.ErrorType) string {
123123
return "EXCEEDED_MAX"
124124
case pkgErr.ErrorTypeOutOfRange:
125125
return "OUT_OF_RANGE"
126+
case pkgErr.ErrorTypeVariationInUseByOffVariation:
127+
return "VARIATION_IN_USE_BY_OFF_VARIATION"
128+
case pkgErr.ErrorTypeVariationInUseByDefaultStrategy:
129+
return "VARIATION_IN_USE_BY_DEFAULT_STRATEGY"
130+
case pkgErr.ErrorTypeVariationInUseByTargetingRule:
131+
return "VARIATION_IN_USE_BY_TARGETING_RULE"
132+
case pkgErr.ErrorTypeVariationInUseByIndividualTarget:
133+
return "VARIATION_IN_USE_BY_INDIVIDUAL_TARGETING"
134+
case pkgErr.ErrorTypeVariationInUseByPrerequisite:
135+
return "VARIATION_IN_USE_BY_PREREQUISITE"
136+
case pkgErr.ErrorTypeVariationInUseByFeatureFlagRule:
137+
return "VARIATION_IN_USE_BY_FEATURE_FLAG_RULE"
126138
default:
127139
return "UNKNOWN"
128140
}
@@ -150,7 +162,13 @@ func convertStatusCode(errorType pkgErr.ErrorType) codes.Code {
150162
return codes.Internal
151163
case pkgErr.ErrorTypeInternal:
152164
return codes.Internal
153-
case pkgErr.ErrorTypeFailedPrecondition:
165+
case pkgErr.ErrorTypeFailedPrecondition,
166+
pkgErr.ErrorTypeVariationInUseByOffVariation,
167+
pkgErr.ErrorTypeVariationInUseByDefaultStrategy,
168+
pkgErr.ErrorTypeVariationInUseByTargetingRule,
169+
pkgErr.ErrorTypeVariationInUseByIndividualTarget,
170+
pkgErr.ErrorTypeVariationInUseByPrerequisite,
171+
pkgErr.ErrorTypeVariationInUseByFeatureFlagRule:
154172
return codes.FailedPrecondition
155173
case pkgErr.ErrorTypeUnavailable:
156174
return codes.Unavailable

pkg/api/api/grpc_status_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,3 +230,71 @@ func TestNewGRPCStatus_EdgeCases(t *testing.T) {
230230
})
231231
}
232232
}
233+
234+
func TestNewGRPCStatus_VariationInUse(t *testing.T) {
235+
t.Parallel()
236+
237+
tests := []struct {
238+
name string
239+
errorType pkgErr.ErrorType
240+
expectedReason string
241+
}{
242+
{
243+
name: "off variation",
244+
errorType: pkgErr.ErrorTypeVariationInUseByOffVariation,
245+
expectedReason: "VARIATION_IN_USE_BY_OFF_VARIATION",
246+
},
247+
{
248+
name: "default strategy",
249+
errorType: pkgErr.ErrorTypeVariationInUseByDefaultStrategy,
250+
expectedReason: "VARIATION_IN_USE_BY_DEFAULT_STRATEGY",
251+
},
252+
{
253+
name: "targeting rule",
254+
errorType: pkgErr.ErrorTypeVariationInUseByTargetingRule,
255+
expectedReason: "VARIATION_IN_USE_BY_TARGETING_RULE",
256+
},
257+
{
258+
name: "individual targeting",
259+
errorType: pkgErr.ErrorTypeVariationInUseByIndividualTarget,
260+
expectedReason: "VARIATION_IN_USE_BY_INDIVIDUAL_TARGETING",
261+
},
262+
{
263+
name: "prerequisite",
264+
errorType: pkgErr.ErrorTypeVariationInUseByPrerequisite,
265+
expectedReason: "VARIATION_IN_USE_BY_PREREQUISITE",
266+
},
267+
{
268+
name: "feature flag rule",
269+
errorType: pkgErr.ErrorTypeVariationInUseByFeatureFlagRule,
270+
expectedReason: "VARIATION_IN_USE_BY_FEATURE_FLAG_RULE",
271+
},
272+
}
273+
274+
for _, tt := range tests {
275+
t.Run(tt.name, func(t *testing.T) {
276+
t.Parallel()
277+
278+
st := NewGRPCStatus(pkgErr.NewErrorVariationInUse(
279+
pkgErr.FeaturePackageName,
280+
tt.errorType,
281+
"variation in use",
282+
map[string]string{"featureId": "feature-2", "featureName": "Flag B"},
283+
))
284+
285+
assert.Equal(t, codes.FailedPrecondition, st.Code())
286+
assert.Equal(t, "feature:variation in use", st.Message())
287+
288+
for _, detail := range st.Details() {
289+
errorInfo, ok := detail.(*errdetails.ErrorInfo)
290+
if !ok {
291+
continue
292+
}
293+
assert.Equal(t, tt.expectedReason, errorInfo.Reason)
294+
assert.Equal(t, string(tt.errorType), errorInfo.Metadata["messageKey"])
295+
assert.Equal(t, "feature-2", errorInfo.Metadata["featureId"])
296+
assert.Equal(t, "Flag B", errorInfo.Metadata["featureName"])
297+
}
298+
})
299+
}
300+
}

pkg/error/error.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package error
1717
import (
1818
"errors"
1919
"fmt"
20+
"maps"
2021
"strconv"
2122
"strings"
2223
)
@@ -62,6 +63,14 @@ const (
6263
ErrorTypeExceededMax ErrorType = "ExceededMaxError"
6364
ErrorTypeOutOfRange ErrorType = "OutOfRangeError"
6465
ErrorTypeDifferentVariationsSize ErrorType = "DifferentVariationsSizeError"
66+
67+
// A variation cannot be deleted while something still references it.
68+
ErrorTypeVariationInUseByOffVariation ErrorType = "VariationInUseByOffVariationError"
69+
ErrorTypeVariationInUseByDefaultStrategy ErrorType = "VariationInUseByDefaultStrategyError"
70+
ErrorTypeVariationInUseByTargetingRule ErrorType = "VariationInUseByTargetingRuleError"
71+
ErrorTypeVariationInUseByIndividualTarget ErrorType = "VariationInUseByIndividualTargetingError"
72+
ErrorTypeVariationInUseByPrerequisite ErrorType = "VariationInUseByPrerequisiteError"
73+
ErrorTypeVariationInUseByFeatureFlagRule ErrorType = "VariationInUseByFeatureFlagRuleError"
6574
)
6675

6776
type BktError struct {
@@ -100,6 +109,24 @@ func (e *BktError) Wrap(err error) {
100109
e.wrappedError = errors.Join(e.wrappedError, err)
101110
}
102111

112+
// AsVariationInUseError reports whether err says a variation is still referenced.
113+
func AsVariationInUseError(err error) (*BktError, bool) {
114+
var bktErr *BktError
115+
if !errors.As(err, &bktErr) {
116+
return nil, false
117+
}
118+
switch bktErr.ErrorType() {
119+
case ErrorTypeVariationInUseByOffVariation,
120+
ErrorTypeVariationInUseByDefaultStrategy,
121+
ErrorTypeVariationInUseByTargetingRule,
122+
ErrorTypeVariationInUseByIndividualTarget,
123+
ErrorTypeVariationInUseByPrerequisite,
124+
ErrorTypeVariationInUseByFeatureFlagRule:
125+
return bktErr, true
126+
}
127+
return nil, false
128+
}
129+
103130
func newBktError(pkg string, errorType ErrorType, message string) *BktError {
104131
return &BktError{
105132
packageName: pkg,
@@ -206,3 +233,21 @@ func NewErrorOutOfRange(pkg string, message string, field string, min int, max i
206233
func NewErrorDifferentVariationsSize(pkg string, message string) *BktError {
207234
return newBktError(pkg, ErrorTypeDifferentVariationsSize, message)
208235
}
236+
237+
// NewErrorVariationInUse creates an error naming the reference that keeps a
238+
// variation from being deleted. keyValues may be nil.
239+
func NewErrorVariationInUse(
240+
pkg string,
241+
errorType ErrorType,
242+
message string,
243+
keyValues map[string]string,
244+
) *BktError {
245+
embedded := make(map[string]string, len(keyValues))
246+
maps.Copy(embedded, keyValues)
247+
return &BktError{
248+
packageName: pkg,
249+
errorType: errorType,
250+
message: message,
251+
embeddedKeyValues: embedded,
252+
}
253+
}

pkg/error/error_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,3 +323,100 @@ func TestErrorAs(t *testing.T) {
323323
t.Error("Expected fieldErr to wrap originalErr")
324324
}
325325
}
326+
327+
func TestNewErrorVariationInUse(t *testing.T) {
328+
t.Parallel()
329+
330+
err := NewErrorVariationInUse(
331+
FeaturePackageName,
332+
ErrorTypeVariationInUseByPrerequisite,
333+
"feature: variation variation-1 is used as a prerequisite by feature feature-2",
334+
map[string]string{"featureId": "feature-2", "featureName": "Flag B"},
335+
)
336+
337+
assert.Equal(t, FeaturePackageName, err.PackageName())
338+
assert.Equal(t, ErrorTypeVariationInUseByPrerequisite, err.ErrorType())
339+
assert.Equal(t, "VariationInUseByPrerequisiteError", err.MessageKey())
340+
assert.Equal(
341+
t,
342+
"feature:feature: variation variation-1 is used as a prerequisite by feature feature-2",
343+
err.Error(),
344+
)
345+
assert.Equal(
346+
t,
347+
map[string]string{"featureId": "feature-2", "featureName": "Flag B"},
348+
err.EmbeddedKeyValues(),
349+
)
350+
}
351+
352+
func TestNewErrorVariationInUse_CopiesKeyValues(t *testing.T) {
353+
t.Parallel()
354+
355+
keyValues := map[string]string{"featureId": "feature-2"}
356+
err := NewErrorVariationInUse(
357+
FeaturePackageName,
358+
ErrorTypeVariationInUseByPrerequisite,
359+
"feature: variation in use",
360+
keyValues,
361+
)
362+
keyValues["featureId"] = "mutated"
363+
364+
assert.Equal(t, "feature-2", err.EmbeddedKeyValues()["featureId"])
365+
}
366+
367+
func TestAsVariationInUseError(t *testing.T) {
368+
t.Parallel()
369+
370+
patterns := []struct {
371+
desc string
372+
err error
373+
expected bool
374+
}{
375+
{
376+
desc: "false: nil",
377+
err: nil,
378+
expected: false,
379+
},
380+
{
381+
desc: "false: not a BktError",
382+
err: errors.New("something else"),
383+
expected: false,
384+
},
385+
{
386+
desc: "false: another BktError type",
387+
err: NewErrorFailedPrecondition(FeaturePackageName, "failed precondition"),
388+
expected: false,
389+
},
390+
{
391+
desc: "true: off variation",
392+
err: NewErrorVariationInUse(
393+
FeaturePackageName, ErrorTypeVariationInUseByOffVariation, "in use", nil,
394+
),
395+
expected: true,
396+
},
397+
{
398+
desc: "true: individual targeting",
399+
err: NewErrorVariationInUse(
400+
FeaturePackageName, ErrorTypeVariationInUseByIndividualTarget, "in use", nil,
401+
),
402+
expected: true,
403+
},
404+
{
405+
desc: "true: feature flag rule",
406+
err: NewErrorVariationInUse(
407+
FeaturePackageName, ErrorTypeVariationInUseByFeatureFlagRule, "in use", nil,
408+
),
409+
expected: true,
410+
},
411+
}
412+
for _, p := range patterns {
413+
t.Run(p.desc, func(t *testing.T) {
414+
t.Parallel()
415+
bktErr, ok := AsVariationInUseError(p.err)
416+
assert.Equal(t, p.expected, ok)
417+
if !p.expected {
418+
assert.Nil(t, bktErr)
419+
}
420+
})
421+
}
422+
}

pkg/feature/api/error.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -207,11 +207,6 @@ var (
207207
pkgErr.NewErrorFailedPrecondition(
208208
pkgErr.FeaturePackageName,
209209
"can't archive because this feature is used as a prerequsite"))
210-
statusVariationInUseByOtherFeatures = api.NewGRPCStatus(
211-
pkgErr.NewErrorFailedPrecondition(
212-
pkgErr.FeaturePackageName,
213-
"can't remove this variation because it is used as a prerequisite or rule in other features",
214-
))
215210
// flag trigger
216211
statusMissingTriggerFeatureID = api.NewGRPCStatus(
217212
pkgErr.NewErrorInvalidArgEmpty(pkgErr.FeaturePackageName, "missing trigger feature id", "FeatureFlagID"))

pkg/feature/api/feature.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
evaluation "github.com/bucketeer-io/bucketeer/v2/evaluation/go"
3232
"github.com/bucketeer-io/bucketeer/v2/pkg/api/api"
3333
domainevent "github.com/bucketeer-io/bucketeer/v2/pkg/domainevent/domain"
34+
pkgErr "github.com/bucketeer-io/bucketeer/v2/pkg/error"
3435
experimentdomain "github.com/bucketeer-io/bucketeer/v2/pkg/experiment/domain"
3536
"github.com/bucketeer-io/bucketeer/v2/pkg/feature/domain"
3637
"github.com/bucketeer-io/bucketeer/v2/pkg/feature/scheduled"
@@ -802,6 +803,7 @@ func (s *FeatureService) updateFeatureWithinTransaction(
802803
},
803804
)
804805
if err != nil {
806+
s.logVariationInUseError(ctx, err, req)
805807
return nil, nil, err
806808
}
807809
if err := s.upsertTags(ctx, updated.Tags, req.EnvironmentId); err != nil {
@@ -824,6 +826,7 @@ func (s *FeatureService) updateFeatureWithinTransaction(
824826
}
825827
// Validate that variations being deleted are not used in other features
826828
if err := validateVariationDeletion(req.VariationChanges, features, req.Id); err != nil {
829+
s.logVariationInUseError(ctx, err, req)
827830
return nil, nil, err
828831
}
829832
event, err := domainevent.NewEvent(
@@ -1014,6 +1017,36 @@ func (s *FeatureService) DeleteFeature(
10141017
return &featureproto.DeleteFeatureResponse{}, nil
10151018
}
10161019

1020+
// logVariationInUseError records which variation blocked the update and what
1021+
// still references it. Validation failures are otherwise not logged at all.
1022+
func (s *FeatureService) logVariationInUseError(
1023+
ctx context.Context,
1024+
err error,
1025+
req *featureproto.UpdateFeatureRequest,
1026+
) {
1027+
bktErr, ok := pkgErr.AsVariationInUseError(err)
1028+
if !ok {
1029+
return
1030+
}
1031+
deleted := make([]string, 0, len(req.VariationChanges))
1032+
for _, change := range req.VariationChanges {
1033+
if change.ChangeType == featureproto.ChangeType_DELETE {
1034+
deleted = append(deleted, change.Variation.GetId())
1035+
}
1036+
}
1037+
s.logger.Error(
1038+
"Failed to update feature because a variation is still in use",
1039+
log.FieldsFromIncomingContext(ctx).AddFields(
1040+
zap.Error(err),
1041+
zap.String("environmentId", req.EnvironmentId),
1042+
zap.String("featureId", req.Id),
1043+
zap.Strings("deletedVariationIds", deleted),
1044+
// Empty when the reference is in the flag being updated.
1045+
zap.Any("reference", bktErr.EmbeddedKeyValues()),
1046+
)...,
1047+
)
1048+
}
1049+
10171050
func (s *FeatureService) convUpdateFeatureError(err error) error {
10181051
switch err {
10191052
case v2fs.ErrFeatureNotFound,

pkg/feature/api/validation.go

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ package api
1616

1717
import (
1818
"context"
19-
"errors"
2019
"regexp"
2120

2221
"github.com/bucketeer-io/bucketeer/v2/pkg/api/api"
@@ -428,17 +427,11 @@ func validateVariationDeletion(
428427
dependentFeaturesSlice = append(dependentFeaturesSlice, f)
429428
}
430429

431-
// Check if the deleted variation is used as a prerequisite or rule in other features
432-
if err := featuredomain.ValidateVariationUsage(
430+
// Check if the deleted variation is used as a prerequisite or rule in other features.
431+
// The error already names the reference, so it is returned as-is.
432+
return featuredomain.ValidateVariationUsage(
433433
dependentFeaturesSlice,
434434
targetFeatureID,
435435
deletedVariations,
436-
); err != nil {
437-
if errors.Is(err, featuredomain.ErrVariationInUse) {
438-
return statusVariationInUseByOtherFeatures.Err()
439-
}
440-
return err
441-
}
442-
443-
return nil
436+
)
444437
}

0 commit comments

Comments
 (0)