Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions libdd-ffe-ffi/src/assignment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,6 @@ impl From<&ResolutionDetails> for Reason {
Ok(assignment) => assignment.reason.into(),
Err(EvaluationError::FlagDisabled) => Reason::Disabled,
Err(EvaluationError::DefaultAllocationNull) => Reason::Default,
Err(EvaluationError::FlagConfigurationInvalid) => Reason::Default,
Err(_) => Reason::Error,
}
}
Expand Down Expand Up @@ -409,7 +408,7 @@ impl From<&EvaluationError> for ErrorCode {
EvaluationError::TypeMismatch { .. } => ErrorCode::TypeMismatch,
EvaluationError::TargetingKeyMissing => ErrorCode::TargetingKeyMissing,
EvaluationError::ConfigurationParseError => ErrorCode::ParseError,
EvaluationError::FlagConfigurationInvalid => ErrorCode::Ok,
EvaluationError::FlagConfigurationInvalid => ErrorCode::ParseError,
EvaluationError::ConfigurationMissing => ErrorCode::ProviderNotReady,
EvaluationError::FlagUnrecognizedOrDisabled => ErrorCode::FlagNotFound,
EvaluationError::FlagDisabled => ErrorCode::Ok,
Expand Down Expand Up @@ -486,3 +485,16 @@ pub unsafe extern "C" fn ddog_ffe_assignment_drop(assignment: *mut Handle<Resolu
// SAFETY: the caller must ensure that assignment is valid
unsafe { Handle::free(assignment) }
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn rejected_flag_is_exposed_as_parse_error() {
let details = ResolutionDetails::from(EvaluationError::FlagConfigurationInvalid);

assert!(matches!(Reason::from(&details), Reason::Error));
assert_eq!(ErrorCode::from(&details), ErrorCode::ParseError);
}
}
12 changes: 5 additions & 7 deletions libdd-ffe-test-suite/tests/canonical_fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,21 +125,19 @@ fn reason_from_assignment(reason: AssignmentReason) -> &'static str {
fn reason_from_error(err: &EvaluationError) -> &'static str {
match err {
EvaluationError::FlagDisabled => "DISABLED",
EvaluationError::DefaultAllocationNull | EvaluationError::FlagConfigurationInvalid => {
"DEFAULT"
}
EvaluationError::DefaultAllocationNull => "DEFAULT",
_ => "ERROR",
}
}

fn error_code_from_error(err: &EvaluationError) -> Option<&'static str> {
match err {
EvaluationError::FlagDisabled
| EvaluationError::DefaultAllocationNull
| EvaluationError::FlagConfigurationInvalid => None,
EvaluationError::FlagDisabled | EvaluationError::DefaultAllocationNull => None,
EvaluationError::TypeMismatch { .. } => Some("TYPE_MISMATCH"),
EvaluationError::TargetingKeyMissing => Some("TARGETING_KEY_MISSING"),
EvaluationError::ConfigurationParseError => Some("PARSE_ERROR"),
EvaluationError::ConfigurationParseError | EvaluationError::FlagConfigurationInvalid => {
Some("PARSE_ERROR")
}
EvaluationError::ConfigurationMissing => Some("PROVIDER_NOT_READY"),
EvaluationError::FlagUnrecognizedOrDisabled => Some("FLAG_NOT_FOUND"),
EvaluationError::Internal(_) => Some("GENERAL"),
Expand Down
5 changes: 4 additions & 1 deletion libdd-ffe/src/rules_based/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ pub enum EvaluationError {
ConfigurationParseError,

/// A requested flag exists in the configuration payload, but its per-flag configuration is
/// invalid or unsupported by this SDK. The SDK should return the caller default for that flag.
/// invalid or unsupported by this SDK. Failures encountered while compiling an individual
/// flag are converted to `FlagConfigurationInvalid` at the per-flag ingestion boundary. The
/// SDK should return the caller default and expose this as a parse error for that flag without
/// invalidating the rest of the configuration.
#[error("flag configuration is invalid or unsupported")]
FlagConfigurationInvalid,

Expand Down
68 changes: 68 additions & 0 deletions libdd-ffe/src/rules_based/eval/eval_assignment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,71 @@ impl Shard {
self.ranges.iter().any(|range| range.contains(h))
}
}

#[cfg(test)]
mod tests {
use std::{collections::HashMap, sync::Arc};

use super::*;
use crate::rules_based::{AssignmentReason, AssignmentValue, UniversalFlagConfig};

#[test]
fn invalid_flag_does_not_poison_valid_neighbor() {
let config = UniversalFlagConfig::from_json(
br#"
{
"createdAt": "2026-08-11T00:00:00Z",
"environment": {"name": "test"},
"flags": {
"invalid-regex": {
"key": "invalid-regex",
"enabled": true,
"variationType": "STRING",
"variations": {"trap": {"key": "trap", "value": "trap"}},
"allocations": [{
"key": "invalid",
"rules": [{"conditions": [{
"attribute": "email",
"operator": "MATCHES",
"value": "[invalid"
}]}],
"splits": [{"variationKey": "trap", "shards": []}]
}]
},
"valid": {
"key": "valid",
"enabled": true,
"variationType": "STRING",
"variations": {"on": {"key": "on", "value": "valid"}},
"allocations": [{
"key": "static",
"splits": [{"variationKey": "on", "shards": []}]
}]
}
}
}
"#
.to_vec(),
)
.unwrap();
let config = Configuration::from_server_response(config);
let context = EvaluationContext::new(None, Arc::new(HashMap::new()));
let now = chrono::Utc::now();

assert_eq!(
config
.eval_flag("invalid-regex", &context, ExpectedFlagType::String, now)
.unwrap_err(),
EvaluationError::FlagConfigurationInvalid
);

let assignment = config
.eval_flag("valid", &context, ExpectedFlagType::String, now)
.unwrap();
assert!(matches!(
assignment.value,
AssignmentValue::String(ref value) if value.as_str() == "valid"
));
assert_eq!(assignment.reason, AssignmentReason::Static);
}
}
4 changes: 3 additions & 1 deletion libdd-ffe/src/rules_based/eval/eval_rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ impl ConditionCheck {
} => {
let attr_str = attribute?.as_str()?;
let attr_version = semver::Version::parse(attr_str.as_ref()).ok()?;
let ordering = attr_version.cmp(comparand);
let ordering = attr_version.cmp_precedence(comparand);
match operator {
SemverComparisonOperator::Eq => ordering.is_eq(),
SemverComparisonOperator::Neq => !ordering.is_eq(),
Expand Down Expand Up @@ -315,6 +315,7 @@ mod tests {
comparand: semver("1.2.3"),
};
assert!(check.eval(Some(&"1.2.3".into())));
assert!(check.eval(Some(&"1.2.3+build.42".into())));
assert!(!check.eval(Some(&"1.2.4".into())));
assert!(!check.eval(Some(&"1.2.2".into())));
assert!(!check.eval(None));
Expand All @@ -327,6 +328,7 @@ mod tests {
comparand: semver("1.2.3"),
};
assert!(!check.eval(Some(&"1.2.3".into())));
assert!(!check.eval(Some(&"1.2.3+build.42".into())));
assert!(check.eval(Some(&"1.2.4".into())));
assert!(!check.eval(None));
}
Expand Down
4 changes: 2 additions & 2 deletions libdd-ffe/src/rules_based/ufc/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,8 @@ pub(crate) enum ConditionCheck {
},
Regex {
expected_match: bool,
// As regex is supplied by user, we allow regex parse failure to not fail parsing and
// evaluation. Invalid regexes are simply ignored.
// Compile user-supplied regexes during ingestion so a failure rejects only the containing
// flag before evaluation.
regex: Regex,
},
Membership {
Expand Down
Loading