Skip to content

Commit d3c4ec4

Browse files
authored
fix!: flagd, behavioral tests conformance (evaluation and config) (#76)
1 parent 9b78024 commit d3c4ec4

File tree

8 files changed

+910
-48
lines changed

8 files changed

+910
-48
lines changed

crates/flagd/src/lib.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,9 @@ pub struct FlagdOptions {
261261
pub stream_deadline_ms: u32,
262262
/// Offline polling interval in milliseconds
263263
pub offline_poll_interval_ms: Option<u32>,
264+
/// Provider ID for identifying this provider instance to flagd
265+
/// Used in in-process resolver for sync requests
266+
pub provider_id: Option<String>,
264267
}
265268
/// Type of resolver to use for flag evaluation
266269
#[derive(Debug, Clone, PartialEq)]
@@ -336,9 +339,12 @@ impl Default for FlagdOptions {
336339
.and_then(|s| s.parse().ok())
337340
.unwrap_or(5000),
338341
),
342+
provider_id: std::env::var("FLAGD_PROVIDER_ID").ok(),
339343
};
340344

341-
if options.source_configuration.is_some() && options.resolver_type != ResolverType::Rpc {
345+
let resolver_env_set = std::env::var("FLAGD_RESOLVER").is_ok();
346+
if options.source_configuration.is_some() && !resolver_env_set {
347+
// Only override to File if FLAGD_RESOLVER wasn't explicitly set
342348
options.resolver_type = ResolverType::File;
343349
}
344350

@@ -360,6 +366,13 @@ impl FlagdProvider {
360366
pub async fn new(options: FlagdOptions) -> Result<Self, FlagdError> {
361367
debug!("Initializing FlagdProvider with options: {:?}", options);
362368

369+
// Validate File resolver configuration
370+
if options.resolver_type == ResolverType::File && options.source_configuration.is_none() {
371+
return Err(FlagdError::Config(
372+
"File resolver requires 'source_configuration' (FLAGD_OFFLINE_FLAG_SOURCE_PATH) to be set".to_string()
373+
));
374+
}
375+
363376
let provider: Arc<dyn FeatureProvider + Send + Sync> = match options.resolver_type {
364377
ResolverType::Rpc => {
365378
debug!("Using RPC resolver");
@@ -377,7 +390,9 @@ impl FlagdProvider {
377390
debug!("Using file resolver");
378391
Arc::new(
379392
FileResolver::new(
380-
options.source_configuration.unwrap(),
393+
options
394+
.source_configuration
395+
.expect("source_configuration validated above"),
381396
options.cache_settings.clone(),
382397
)
383398
.await?,

crates/flagd/src/resolver/in_process/storage/connector/grpc.rs

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ pub struct GrpcStreamConnector {
2828
retry_backoff_max_ms: u32,
2929
retry_grace_period: u32,
3030
stream_deadline_ms: u32,
31-
authority: String, // desired authority, e.g. "b-features-api.service"
31+
authority: String, // desired authority, e.g. "b-features-api.service"
32+
provider_id: String, // provider identifier for sync requests
3233
}
3334

3435
impl GrpcStreamConnector {
@@ -52,6 +53,10 @@ impl GrpcStreamConnector {
5253
retry_grace_period: options.retry_grace_period,
5354
stream_deadline_ms: options.stream_deadline_ms,
5455
authority,
56+
provider_id: options
57+
.provider_id
58+
.clone()
59+
.unwrap_or_else(|| "rust-flagd-provider".to_string()),
5560
}
5661
}
5762

@@ -119,7 +124,7 @@ impl GrpcStreamConnector {
119124
// Create the gRPC client with no interceptor because the endpoint already carries the desired authority.
120125
let mut client = FlagSyncServiceClient::new(channel);
121126
let request = tonic::Request::new(SyncFlagsRequest {
122-
provider_id: "rust-flagd-provider".to_string(),
127+
provider_id: self.provider_id.clone(),
123128
selector: self.selector.clone().unwrap_or_default(),
124129
});
125130
debug!("Sending sync request with selector: {:?}", self.selector);
@@ -227,24 +232,17 @@ mod tests {
227232
drop(listener);
228233

229234
// Create options configured for a failing connection.
230-
let options = FlagdOptions {
231-
host: addr.ip().to_string(),
232-
resolver_type: crate::ResolverType::InProcess,
233-
port: addr.port(),
234-
target_uri: None,
235-
deadline_ms: 100, // Short timeout for fast failures
236-
retry_backoff_ms: 100,
237-
retry_backoff_max_ms: 400,
238-
retry_grace_period: 3,
239-
stream_deadline_ms: 500,
240-
tls: false,
241-
cert_path: None,
242-
selector: None,
243-
socket_path: None,
244-
cache_settings: None,
245-
source_configuration: None,
246-
offline_poll_interval_ms: None,
247-
};
235+
let mut options = FlagdOptions::default();
236+
options.host = addr.ip().to_string();
237+
options.resolver_type = crate::ResolverType::InProcess;
238+
options.port = addr.port();
239+
options.deadline_ms = 100; // Short timeout for fast failures
240+
options.retry_backoff_ms = 100;
241+
options.retry_backoff_max_ms = 400;
242+
options.retry_grace_period = 3;
243+
options.stream_deadline_ms = 500;
244+
options.tls = false;
245+
options.cache_settings = None;
248246

249247
let target = format!("{}:{}", addr.ip(), addr.port());
250248
let connector =

crates/flagd/src/resolver/rpc.rs

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,27 @@ fn convert_proto_metadata(metadata: prost_types::Struct) -> FlagMetadata {
8585
FlagMetadata { values }
8686
}
8787

88+
/// Maps gRPC status codes to OpenFeature error codes
89+
///
90+
/// This ensures consistent error handling across different resolver types
91+
/// and proper conformance with the OpenFeature specification.
92+
fn map_grpc_status_to_error_code(status: &tonic::Status) -> EvaluationErrorCode {
93+
use tonic::Code;
94+
match status.code() {
95+
Code::NotFound => EvaluationErrorCode::FlagNotFound,
96+
Code::InvalidArgument => EvaluationErrorCode::InvalidContext,
97+
Code::Unauthenticated | Code::PermissionDenied => {
98+
EvaluationErrorCode::General("authentication/authorization error".to_string())
99+
}
100+
Code::FailedPrecondition => EvaluationErrorCode::TypeMismatch,
101+
Code::DeadlineExceeded | Code::Cancelled => {
102+
EvaluationErrorCode::General("request timeout or cancelled".to_string())
103+
}
104+
Code::Unavailable => EvaluationErrorCode::General("service unavailable".to_string()),
105+
_ => EvaluationErrorCode::General(format!("{:?}", status.code())),
106+
}
107+
}
108+
88109
pub struct RpcResolver {
89110
client: ClientType,
90111
metadata: OnceLock<ProviderMetadata>,
@@ -214,7 +235,7 @@ impl FeatureProvider for RpcResolver {
214235
Err(status) => {
215236
error!(flag_key, error = %status, "failed to resolve boolean flag");
216237
Err(EvaluationError {
217-
code: EvaluationErrorCode::General(status.code().to_string()),
238+
code: map_grpc_status_to_error_code(&status),
218239
message: Some(status.message().to_string()),
219240
})
220241
}
@@ -247,7 +268,7 @@ impl FeatureProvider for RpcResolver {
247268
Err(status) => {
248269
error!(flag_key, error = %status, "failed to resolve string flag");
249270
Err(EvaluationError {
250-
code: EvaluationErrorCode::General(status.code().to_string()),
271+
code: map_grpc_status_to_error_code(&status),
251272
message: Some(status.message().to_string()),
252273
})
253274
}
@@ -280,7 +301,7 @@ impl FeatureProvider for RpcResolver {
280301
Err(status) => {
281302
error!(flag_key, error = %status, "failed to resolve float flag");
282303
Err(EvaluationError {
283-
code: EvaluationErrorCode::General(status.code().to_string()),
304+
code: map_grpc_status_to_error_code(&status),
284305
message: Some(status.message().to_string()),
285306
})
286307
}
@@ -313,7 +334,7 @@ impl FeatureProvider for RpcResolver {
313334
Err(status) => {
314335
error!(flag_key, error = %status, "failed to resolve integer flag");
315336
Err(EvaluationError {
316-
code: EvaluationErrorCode::General(status.code().to_string()),
337+
code: map_grpc_status_to_error_code(&status),
317338
message: Some(status.message().to_string()),
318339
})
319340
}
@@ -346,7 +367,7 @@ impl FeatureProvider for RpcResolver {
346367
Err(status) => {
347368
error!(flag_key, error = %status, "failed to resolve struct flag");
348369
Err(EvaluationError {
349-
code: EvaluationErrorCode::General(status.code().to_string()),
370+
code: map_grpc_status_to_error_code(&status),
350371
message: Some(status.message().to_string()),
351372
})
352373
}
@@ -765,4 +786,44 @@ mod tests {
765786
// Clean shutdown
766787
server_handle.abort();
767788
}
789+
790+
#[test]
791+
fn test_grpc_error_code_mapping() {
792+
use tonic::Code;
793+
794+
// Test NOT_FOUND -> FlagNotFound
795+
let status = tonic::Status::new(Code::NotFound, "Flag not found");
796+
let error_code = map_grpc_status_to_error_code(&status);
797+
assert!(matches!(error_code, EvaluationErrorCode::FlagNotFound));
798+
799+
// Test INVALID_ARGUMENT -> InvalidContext
800+
let status = tonic::Status::new(Code::InvalidArgument, "Invalid context");
801+
let error_code = map_grpc_status_to_error_code(&status);
802+
assert!(matches!(error_code, EvaluationErrorCode::InvalidContext));
803+
804+
// Test UNAUTHENTICATED -> General
805+
let status = tonic::Status::new(Code::Unauthenticated, "Not authenticated");
806+
let error_code = map_grpc_status_to_error_code(&status);
807+
assert!(matches!(error_code, EvaluationErrorCode::General(_)));
808+
809+
// Test PERMISSION_DENIED -> General
810+
let status = tonic::Status::new(Code::PermissionDenied, "Access denied");
811+
let error_code = map_grpc_status_to_error_code(&status);
812+
assert!(matches!(error_code, EvaluationErrorCode::General(_)));
813+
814+
// Test FAILED_PRECONDITION -> TypeMismatch
815+
let status = tonic::Status::new(Code::FailedPrecondition, "Type mismatch");
816+
let error_code = map_grpc_status_to_error_code(&status);
817+
assert!(matches!(error_code, EvaluationErrorCode::TypeMismatch));
818+
819+
// Test DEADLINE_EXCEEDED -> General
820+
let status = tonic::Status::new(Code::DeadlineExceeded, "Timeout");
821+
let error_code = map_grpc_status_to_error_code(&status);
822+
assert!(matches!(error_code, EvaluationErrorCode::General(_)));
823+
824+
// Test UNAVAILABLE -> General
825+
let status = tonic::Status::new(Code::Unavailable, "Service unavailable");
826+
let error_code = map_grpc_status_to_error_code(&status);
827+
assert!(matches!(error_code, EvaluationErrorCode::General(_)));
828+
}
768829
}

crates/flagd/tests/gherkin_config_test.rs

Lines changed: 64 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,24 @@ impl ConfigWorld {
2424

2525
fn clear(&mut self) {
2626
// SAFETY: Removing environment variables is safe here because:
27-
// 1. We're only removing variables that were set during this specific test scenario
28-
// 2. The test is protected by #[serial_test::serial]
29-
// 3. This prevents test pollution between scenarios
30-
// 4. All variables being removed are tracked in world.env_vars
27+
// 1. The test is protected by #[serial_test::serial]
28+
// 2. This prevents test pollution between scenarios
29+
// 3. We clear env vars that were set in previous scenarios
30+
31+
// Clear env vars that were set in the previous scenario
3132
for key in self.env_vars.keys() {
3233
unsafe {
3334
std::env::remove_var(key);
3435
}
3536
}
36-
self.env_vars.clear();
3737

38+
// Also explicitly clear FLAGD_OFFLINE_FLAG_SOURCE_PATH because it can affect resolver type
39+
// via FlagdOptions::default() logic, even if not tracked in world.env_vars
40+
unsafe {
41+
std::env::remove_var("FLAGD_OFFLINE_FLAG_SOURCE_PATH");
42+
}
43+
44+
self.env_vars.clear();
3845
self.options = FlagdOptions::default();
3946
self.provider = None;
4047
self.option_values.clear();
@@ -106,43 +113,60 @@ async fn env_with_value(world: &mut ConfigWorld, env: String, value: String) {
106113

107114
#[when(expr = "a config was initialized")]
108115
async fn initialize_config(world: &mut ConfigWorld) {
116+
// Start with defaults (which reads from environment variables)
109117
let mut options = FlagdOptions::default();
118+
let mut resolver_explicitly_set = false;
110119

111-
// Handle resolver type first
112-
if let Some(resolver) = world.option_values.get("resolver") {
120+
// Apply env vars from world.env_vars to ensure they take precedence
121+
// This handles cases where env vars were set in test steps but timing issues
122+
// prevent FlagdOptions::default() from reading them correctly
123+
if let Some(resolver) = world.env_vars.get("FLAGD_RESOLVER") {
113124
options.resolver_type = match resolver.to_uppercase().as_str() {
114125
"RPC" => ResolverType::Rpc,
115126
"REST" => ResolverType::Rest,
116127
"IN-PROCESS" | "INPROCESS" => ResolverType::InProcess,
117128
"FILE" | "OFFLINE" => ResolverType::File,
118129
_ => ResolverType::Rpc,
119130
};
120-
} else if let Ok(resolver) = std::env::var("FLAGD_RESOLVER") {
131+
resolver_explicitly_set = true;
132+
// Update port based on resolver type when set via env var
133+
options.port = match options.resolver_type {
134+
ResolverType::Rpc => 8013,
135+
ResolverType::InProcess => 8015,
136+
_ => options.port,
137+
};
138+
}
139+
140+
// Handle explicit options - these override env vars
141+
if let Some(resolver) = world.option_values.get("resolver") {
121142
options.resolver_type = match resolver.to_uppercase().as_str() {
122143
"RPC" => ResolverType::Rpc,
123144
"REST" => ResolverType::Rest,
124145
"IN-PROCESS" | "INPROCESS" => ResolverType::InProcess,
125146
"FILE" | "OFFLINE" => ResolverType::File,
126147
_ => ResolverType::Rpc,
127148
};
149+
resolver_explicitly_set = true;
150+
// Update port based on resolver type when explicitly set
151+
options.port = match options.resolver_type {
152+
ResolverType::Rpc => 8013,
153+
ResolverType::InProcess => 8015,
154+
_ => options.port,
155+
};
128156
}
129157

130-
// Set default port based on resolver type
131-
options.port = match options.resolver_type {
132-
ResolverType::Rpc => 8013,
133-
ResolverType::InProcess => 8015,
134-
_ => options.port,
135-
};
136-
137-
// Handle source configuration after resolver type
158+
// Handle source configuration - may override resolver type for backwards compatibility
159+
// BUT only if resolver wasn't explicitly set to "rpc"
138160
if let Some(source) = world.option_values.get("offlineFlagSourcePath") {
139161
options.source_configuration = Some(source.clone());
140-
if options.resolver_type != ResolverType::Rpc {
162+
// For backwards compatibility: if offline path is set, switch to File resolver
163+
// UNLESS resolver was explicitly set to "rpc" (in which case keep it as "rpc")
164+
if !resolver_explicitly_set || options.resolver_type != ResolverType::Rpc {
141165
options.resolver_type = ResolverType::File;
142166
}
143167
}
144168

145-
// Handle remaining explicit options
169+
// Handle remaining explicit options (these override env vars)
146170
if let Some(host) = world.option_values.get("host") {
147171
options.host = host.clone();
148172
}
@@ -217,6 +241,9 @@ async fn initialize_config(world: &mut ConfigWorld) {
217241
if let Some(selector) = world.option_values.get("selector") {
218242
options.selector = Some(selector.clone());
219243
}
244+
if let Some(provider_id) = world.option_values.get("providerId") {
245+
options.provider_id = Some(provider_id.clone());
246+
}
220247
if let Some(max_size) = world
221248
.option_values
222249
.get("maxCacheSize")
@@ -269,12 +296,30 @@ async fn check_option_value(
269296
"retryBackoffMaxMs" => Some(world.options.retry_backoff_max_ms.to_string()),
270297
"retryGracePeriod" => Some(world.options.retry_grace_period.to_string()),
271298
"selector" => world.options.selector.clone(),
299+
"providerId" => world.options.provider_id.clone(),
272300
"socketPath" => world.options.socket_path.clone(),
273301
"streamDeadlineMs" => Some(world.options.stream_deadline_ms.to_string()),
274302
_ => None,
275303
};
276304
let expected = convert_type(&option_type, &value);
277-
assert_eq!(actual, expected, "Option '{}' value mismatch", option);
305+
306+
// For resolver type, do case-insensitive comparison since enum normalizes to lowercase
307+
let actual_normalized = if option == "resolver" {
308+
actual.as_ref().map(|v| v.to_lowercase())
309+
} else {
310+
actual.clone()
311+
};
312+
let expected_normalized = if option == "resolver" {
313+
expected.as_ref().map(|v| v.to_lowercase())
314+
} else {
315+
expected.clone()
316+
};
317+
318+
assert_eq!(
319+
actual_normalized, expected_normalized,
320+
"Option '{}' value mismatch",
321+
option
322+
);
278323
}
279324

280325
#[test(tokio::test)]

0 commit comments

Comments
 (0)