Skip to content
37 changes: 35 additions & 2 deletions openfeature-provider/go/confidence/local_resolver_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,16 @@ func (p *LocalResolverProvider) ObjectEvaluation(
// Get resolver API from factory
resolverAPI := p.factory.GetSwapResolverAPI()

// Resolve flags
response, err := resolverAPI.Resolve(request)
// Create ResolveWithSticky request
stickyRequest := &resolver.ResolveWithStickyRequest{
ResolveRequest: request,
MaterializationsPerUnit: make(map[string]*resolver.MaterializationMap),
FailFastOnSticky: true,
NotProcessSticky: false,
}

// Resolve flags with sticky support
stickyResponse, err := resolverAPI.ResolveWithSticky(stickyRequest)
if err != nil {
log.Printf("Failed to resolve flag '%s': %v", flagPath, err)
return openfeature.InterfaceResolutionDetail{
Expand All @@ -234,6 +242,31 @@ func (p *LocalResolverProvider) ObjectEvaluation(
}
}

// Extract the actual resolve response from the sticky response
var response *resolver.ResolveFlagsResponse
switch result := stickyResponse.ResolveResult.(type) {
case *resolver.ResolveWithStickyResponse_Success_:
response = result.Success.Response
case *resolver.ResolveWithStickyResponse_MissingMaterializations_:
log.Printf("Missing materializations for flag '%s'", flagPath)
return openfeature.InterfaceResolutionDetail{
Value: defaultValue,
ProviderResolutionDetail: openfeature.ProviderResolutionDetail{
Reason: openfeature.ErrorReason,
ResolutionError: openfeature.NewGeneralResolutionError("missing materializations"),
},
}
default:
log.Printf("Unexpected resolve result type for flag '%s'", flagPath)
return openfeature.InterfaceResolutionDetail{
Value: defaultValue,
ProviderResolutionDetail: openfeature.ProviderResolutionDetail{
Reason: openfeature.ErrorReason,
ResolutionError: openfeature.NewGeneralResolutionError("unexpected resolve result"),
},
}
}

// Check if flag was found
if len(response.ResolvedFlags) == 0 {
log.Printf("No active flag '%s' was found", flagPath)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
package confidence

import (
"context"
"testing"

"github.com/open-feature/go-sdk/openfeature"
adminv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/proto/confidence/flags/admin/v1"
iamv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/proto/confidence/iam/v1"
"github.com/tetratelabs/wazero"
"google.golang.org/protobuf/proto"
)

func TestLocalResolverProvider_ReturnsDefaultOnError(t *testing.T) {
ctx := context.Background()
runtime := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig())
defer runtime.Close(ctx)

// Create minimal state with wrong client secret
state := &adminv1.ResolverState{
Flags: []*adminv1.Flag{},
ClientCredentials: []*iamv1.ClientCredential{
{
Credential: &iamv1.ClientCredential_ClientSecret_{
ClientSecret: &iamv1.ClientCredential_ClientSecret{
Secret: "wrong-secret",
},
},
},
},
}
stateBytes, _ := proto.Marshal(state)

flagLogger := NewNoOpWasmFlagLogger()
swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, stateBytes, "test-account")
if err != nil {
t.Fatalf("Failed to create SwapWasmResolverApi: %v", err)
}
defer swap.Close(ctx)

factory := &LocalResolverFactory{
resolverAPI: swap,
}

// Use different client secret that won't match
provider := NewLocalResolverProvider(factory, "test-secret")

evalCtx := openfeature.FlattenedContext{
"user_id": "test-user",
}

t.Run("StringEvaluation returns default on error", func(t *testing.T) {
defaultValue := "default-value"
result := provider.StringEvaluation(ctx, "non-existent-flag.field", defaultValue, evalCtx)

if result.Value != defaultValue {
t.Errorf("Expected default value %v, got %v", defaultValue, result.Value)
}

if result.Reason != openfeature.ErrorReason {
t.Errorf("Expected ErrorReason, got %v", result.Reason)
}

if result.ResolutionError.Error() == "" {
t.Error("Expected ResolutionError to not be empty")
}

t.Logf("✓ StringEvaluation correctly returned default value: %s", defaultValue)
})
}

func TestLocalResolverProvider_ReturnsCorrectValue(t *testing.T) {
ctx := context.Background()
runtime := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig())
defer runtime.Close(ctx)

// Load real test state
testState := loadTestResolverState(t)
testAcctID := loadTestAccountID(t)

flagLogger := NewNoOpWasmFlagLogger()
swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, testState, testAcctID)
if err != nil {
t.Fatalf("Failed to create SwapWasmResolverApi: %v", err)
}
defer swap.Close(ctx)

factory := &LocalResolverFactory{
resolverAPI: swap,
}

// Use the correct client secret from test data
provider := NewLocalResolverProvider(factory, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF")

evalCtx := openfeature.FlattenedContext{
"visitor_id": "tutorial_visitor",
}

t.Run("StringEvaluation returns correct variant value", func(t *testing.T) {
defaultValue := "default-message"
result := provider.StringEvaluation(ctx, "tutorial-feature.message", defaultValue, evalCtx)

// The exciting-welcome variant has a specific message
expectedMessage := "We are very excited to welcome you to Confidence! This is a message from the tutorial flag."

if result.Value != expectedMessage {
t.Errorf("Expected value '%s', got '%s'", expectedMessage, result.Value)
}

if result.Reason != openfeature.TargetingMatchReason {
t.Errorf("Expected TargetingMatchReason, got %v", result.Reason)
}

if result.ResolutionError.Error() != "" {
t.Errorf("Expected no error, got %v", result.ResolutionError)
}
})

t.Run("ObjectEvaluation returns correct variant structure", func(t *testing.T) {
defaultValue := map[string]interface{}{
"message": "default",
"title": "default",
}
result := provider.ObjectEvaluation(ctx, "tutorial-feature", defaultValue, evalCtx)

if result.Value == nil {
t.Fatal("Expected result value to not be nil")
}

resultMap, ok := result.Value.(map[string]interface{})
if !ok {
t.Fatalf("Expected result value to be a map, got %T", result.Value)
}

expectedMessage := "We are very excited to welcome you to Confidence! This is a message from the tutorial flag."
expectedTitle := "Welcome to Confidence!"

if resultMap["message"] != expectedMessage {
t.Errorf("Expected message '%s', got '%v'", expectedMessage, resultMap["message"])
}

if resultMap["title"] != expectedTitle {
t.Errorf("Expected title '%s', got '%v'", expectedTitle, resultMap["title"])
}

if result.Reason != openfeature.TargetingMatchReason {
t.Errorf("Expected TargetingMatchReason, got %v", result.Reason)
}

if result.ResolutionError.Error() != "" {
t.Errorf("Expected no error, got %v", result.ResolutionError)
}
})
}

func TestLocalResolverProvider_MissingMaterializations(t *testing.T) {
ctx := context.Background()
runtime := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig())
defer runtime.Close(ctx)

t.Run("Provider returns resolved value for flag without sticky rules", func(t *testing.T) {
// Load real test state
testState := loadTestResolverState(t)
testAcctID := loadTestAccountID(t)

flagLogger := NewNoOpWasmFlagLogger()
swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, testState, testAcctID)
if err != nil {
t.Fatalf("Failed to create SwapWasmResolverApi: %v", err)
}
defer swap.Close(ctx)

factory := &LocalResolverFactory{
resolverAPI: swap,
}

provider := NewLocalResolverProvider(factory, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF")

evalCtx := openfeature.FlattenedContext{
"visitor_id": "tutorial_visitor",
}

// The tutorial-feature flag in the test data doesn't have materialization requirements
// so resolving with empty materializations should succeed
defaultValue := "default"
result := provider.StringEvaluation(ctx, "tutorial-feature.message", defaultValue, evalCtx)

if result.ResolutionError.Error() != "" {
t.Errorf("Expected successful resolve for flag without sticky rules, got error: %v", result.ResolutionError)
}

if result.Value == defaultValue {
t.Error("Expected resolved value, got default value")
}

if result.Reason != openfeature.TargetingMatchReason {
t.Errorf("Expected TargetingMatchReason, got %v", result.Reason)
}
})

t.Run("Provider returns missing materializations error message", func(t *testing.T) {
// Create state with a flag that requires materializations
stickyState := createStateWithStickyFlag()
accountId := "test-account"

flagLogger := NewNoOpWasmFlagLogger()
swap, err := NewSwapWasmResolverApi(ctx, runtime, defaultWasmBytes, flagLogger, stickyState, accountId)
if err != nil {
t.Fatalf("Failed to create SwapWasmResolverApi: %v", err)
}
defer swap.Close(ctx)

factory := &LocalResolverFactory{
resolverAPI: swap,
}

provider := NewLocalResolverProvider(factory, "test-secret")

evalCtx := openfeature.FlattenedContext{
"user_id": "test-user-123",
}

defaultValue := false
result := provider.BooleanEvaluation(ctx, "sticky-test-flag.enabled", defaultValue, evalCtx)

if result.Value != defaultValue {
t.Errorf("Expected default value %v when materializations missing, got %v", defaultValue, result.Value)
}

if result.Reason != openfeature.ErrorReason {
t.Errorf("Expected ErrorReason when materializations missing, got %v", result.Reason)
}

if result.ResolutionError.Error() == "" {
t.Error("Expected ResolutionError when materializations missing")
}

expectedErrorMsg := "missing materializations"
if result.ResolutionError.Error() != "GENERAL: missing materializations" {
t.Errorf("Expected error message 'GENERAL: %s', got: %v", expectedErrorMsg, result.ResolutionError)
}
})
}
47 changes: 23 additions & 24 deletions openfeature-provider/go/confidence/resolver_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,11 @@ type ResolverApi struct {
runtime wazero.Runtime

// WASM exports
wasmMsgAlloc api.Function
wasmMsgFree api.Function
wasmMsgGuestSetResolverState api.Function
wasmMsgGuestFlushLogs api.Function
wasmMsgGuestResolve api.Function
wasmMsgGuestResolveSimple api.Function
wasmMsgAlloc api.Function
wasmMsgFree api.Function
wasmMsgGuestSetResolverState api.Function
wasmMsgGuestFlushLogs api.Function
wasmMsgGuestResolveWithSticky api.Function

// Flag logger for writing logs
flagLogger WasmFlagLogger
Expand Down Expand Up @@ -117,23 +116,23 @@ func NewResolverApiFromCompiled(ctx context.Context, runtime wazero.Runtime, com
wasmMsgFree := instance.ExportedFunction("wasm_msg_free")
wasmMsgGuestSetResolverState := instance.ExportedFunction("wasm_msg_guest_set_resolver_state")
wasmMsgGuestFlushLogs := instance.ExportedFunction("wasm_msg_guest_flush_logs")
wasmMsgGuestResolve := instance.ExportedFunction("wasm_msg_guest_resolve")
wasmMsgGuestResolveWithSticky := instance.ExportedFunction("wasm_msg_guest_resolve_with_sticky")

if wasmMsgAlloc == nil || wasmMsgFree == nil || wasmMsgGuestSetResolverState == nil || wasmMsgGuestFlushLogs == nil || wasmMsgGuestResolve == nil {
if wasmMsgAlloc == nil || wasmMsgFree == nil || wasmMsgGuestSetResolverState == nil || wasmMsgGuestFlushLogs == nil || wasmMsgGuestResolveWithSticky == nil {
panic("Required WASM exports not found")
}

return &ResolverApi{
instance: instance,
module: compiledModule,
runtime: runtime,
wasmMsgAlloc: wasmMsgAlloc,
wasmMsgFree: wasmMsgFree,
wasmMsgGuestSetResolverState: wasmMsgGuestSetResolverState,
wasmMsgGuestFlushLogs: wasmMsgGuestFlushLogs,
wasmMsgGuestResolve: wasmMsgGuestResolve,
flagLogger: flagLogger,
firstResolve: true,
instance: instance,
module: compiledModule,
runtime: runtime,
wasmMsgAlloc: wasmMsgAlloc,
wasmMsgFree: wasmMsgFree,
wasmMsgGuestSetResolverState: wasmMsgGuestSetResolverState,
wasmMsgGuestFlushLogs: wasmMsgGuestFlushLogs,
wasmMsgGuestResolveWithSticky: wasmMsgGuestResolveWithSticky,
flagLogger: flagLogger,
firstResolve: true,
}
}

Expand Down Expand Up @@ -240,8 +239,8 @@ func (r *ResolverApi) SetResolverState(state []byte, accountId string) error {
// ErrInstanceClosed is returned when the WASM instance is being closed/swapped
var ErrInstanceClosed = errors.New("WASM instance is closed or being replaced")

// Resolve resolves flags using the WASM module
func (r *ResolverApi) Resolve(request *resolver.ResolveFlagsRequest) (*resolver.ResolveFlagsResponse, error) {
// ResolveWithSticky resolves flags with sticky targeting support using the WASM module
func (r *ResolverApi) ResolveWithSticky(request *resolver.ResolveWithStickyRequest) (*resolver.ResolveWithStickyResponse, error) {
// Acquire lock first, then check isClosing flag to prevent race condition
// where instance could be marked as closing between check and lock acquisition.
// If closing, return immediately with ErrInstanceClosed to prevent using stale instance.
Expand All @@ -257,17 +256,17 @@ func (r *ResolverApi) Resolve(request *resolver.ResolveFlagsRequest) (*resolver.
reqPtr := r.transferRequest(request)

// Call the WASM function
results, err := r.wasmMsgGuestResolve.Call(ctx, uint64(reqPtr))
results, err := r.wasmMsgGuestResolveWithSticky.Call(ctx, uint64(reqPtr))
if err != nil {
return nil, fmt.Errorf("failed to call wasm_msg_guest_resolve: %w", err)
return nil, fmt.Errorf("failed to call wasm_msg_guest_resolve_with_sticky: %w", err)
}

// Consume the response
respPtr := uint32(results[0])
response := &resolver.ResolveFlagsResponse{}
response := &resolver.ResolveWithStickyResponse{}
err = r.consumeResponse(respPtr, response)
if err != nil {
log.Printf("Resolve failed with error: %v", err)
log.Printf("ResolveWithSticky failed with error: %v", err)
return nil, err
}

Expand Down
Loading
Loading