feat(rust): add exact function revocation

This commit is contained in:
Xuanwo
2026-08-12 08:06:30 +08:00
parent a49dc5c71d
commit d902144605
4 changed files with 819 additions and 7 deletions
+16
View File
@@ -617,6 +617,22 @@ impl Connection {
self.internal.remove_function_name(name, current).await
}
/// Revoke an exact immutable [`Function`] by opaque id.
///
/// This is a direct synchronous administrator catalog set-bit, not a
/// [`crate::job::Job`], not catalog name removal, not physical deletion,
/// and not [`Function`] or generated-column mutation. The caller supplies
/// an already-validated exact [`Function`] handle; only [`Function::id`]
/// is sent on the wire.
///
/// Local/default backends return [`Error::NotSupported`]. Remote backends
/// complete only when the server reports durable success for that exact
/// id. Repeated logical calls that each receive success succeed; there is
/// no client-side already-revoked branch.
pub async fn revoke_function(&self, function: &Function) -> Result<()> {
self.internal.revoke_function(function).await
}
/// Drop a table in the database.
///
/// # Arguments
+8
View File
@@ -368,6 +368,14 @@ pub trait Database:
}
job_op_not_supported("remove_function_name")
}
/// Revoke an exact immutable [`Function`] by opaque id.
///
/// Direct synchronous administrator catalog set-bit, not a Job, not name
/// removal, and not physical Function deletion. Databases without
/// enterprise catalog mutation return [`crate::Error::NotSupported`].
async fn revoke_function(&self, _function: &Function) -> Result<()> {
job_op_not_supported("revoke_function")
}
/// Open a table in the database
async fn open_table(&self, request: OpenTableRequest) -> Result<Arc<dyn BaseTable>>;
/// Rename a table in the database
+725
View File
@@ -631,6 +631,10 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
super::function::remove_function_name(&self.client, name, current).await
}
async fn revoke_function(&self, function: &Function) -> Result<()> {
super::function::revoke_function(&self.client, function).await
}
async fn table_names(&self, request: TableNamesRequest) -> Result<Vec<String>> {
let mut req = if !request.namespace_path.is_empty() {
let namespace_id =
@@ -4908,4 +4912,725 @@ mod tests {
.expect("table_names after");
assert_eq!(before, after, "empty-name rejection must not mutate tables");
}
// -------------------------------------------------------------------------
// Exact Function revocation
//
// Direct administrator catalog set-bit via POST /v1/functions/revoke.
// Targets an exact Function ID. Not name removal, physical deletion,
// Function mutation, Job, or generated-column mutation.
// -------------------------------------------------------------------------
const REVOKE_FUNCTION_ID: &str = "fn.exact.revoke-handle";
const REVOKE_SERVER_MESSAGE_MARKER: &str =
"SERVER_REVOKE_DIAGNOSTIC_MARKER id=fn.exact.revoke-handle name=text.normalize.revoke-name";
const REVOKE_CATALOG_NAME_MARKER: &str = "text.normalize.revoke-name";
fn sample_revoke_function() -> Function {
let id = FunctionId::try_new(REVOKE_FUNCTION_ID).expect("valid FunctionId");
let signature = FunctionSignature::try_new(
vec![
FunctionParameter::new("text", DataType::Utf8),
FunctionParameter::new("limit", DataType::Int32),
],
FunctionOutput::new(DataType::Utf8, true),
)
.expect("valid FunctionSignature");
Function::new(id, signature)
}
fn revoke_error_chain_text(err: &Error) -> String {
let mut text = format!("{err}\n{err:?}");
let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
while let Some(e) = current {
text.push('\n');
text.push_str(&e.to_string());
text.push('\n');
text.push_str(&format!("{e:?}"));
current = e.source();
}
text
}
fn assert_revoke_payload_free(err: &Error) {
let text = revoke_error_chain_text(err);
assert!(
!text.contains(REVOKE_SERVER_MESSAGE_MARKER),
"server diagnostic marker must be absent from error/debug/source chain: {text}"
);
assert!(
!text.contains(REVOKE_CATALOG_NAME_MARKER),
"catalog name marker must be absent from error/debug/source chain: {text}"
);
assert!(
!text.contains(REVOKE_FUNCTION_ID),
"FunctionId must be absent from error/debug/source chain: {text}"
);
assert!(
!text.contains("SENSITIVE_REVOKE_BODY_MARKER"),
"non-success/malformed body marker must be absent from error/debug/source chain: {text}"
);
}
fn assert_revoke_request(request: &reqwest::Request, expected_function_id: &str) {
assert_eq!(request.method(), &reqwest::Method::POST);
assert_eq!(request.url().path(), "/v1/functions/revoke");
assert!(
request.url().query().is_none(),
"revoke selectors must stay out of the URL query: {}",
request.url()
);
let request_id = request.headers()["x-request-id"]
.to_str()
.expect("x-request-id must be present");
assert!(
!request_id.is_empty(),
"SDK must generate a nonempty request id"
);
let body = request
.body()
.and_then(|b| b.as_bytes())
.expect("revoke request must carry a JSON body");
let actual: Value = serde_json::from_slice(body).expect("revoke body must be JSON");
assert_eq!(
actual,
json!({
"function_id": expected_function_id,
}),
"revoke body must be exactly {{\"function_id\":...}}"
);
let object = actual.as_object().expect("revoke body must be an object");
assert_eq!(
object.len(),
1,
"revoke body must not carry extra fields: {actual}"
);
assert!(
object.get("name").is_none(),
"revoke must not send a catalog name: {actual}"
);
assert!(
object.get("expected_current_function_id").is_none(),
"revoke is not CAS remove and must not send expected_current_function_id: {actual}"
);
assert!(
object.get("current").is_none() && object.get("function").is_none(),
"revoke must not send a Function record: {actual}"
);
assert!(
object.get("signature").is_none() && object.get("format_version").is_none(),
"revoke must not send signature or format_version: {actual}"
);
assert!(
object.get("job_id").is_none() && object.get("idempotency_key").is_none(),
"revoke is not a Job and must not send user idempotency keys: {actual}"
);
assert!(
object.get("user_version").is_none()
&& object.get("reason").is_none()
&& object.get("expiry").is_none()
&& object.get("force").is_none(),
"revoke must not send reason/expiry/force/user-version fields: {actual}"
);
assert!(
!request.url().path().contains("remove"),
"revoke must not use the remove path: {}",
request.url().path()
);
}
/// Exact path/body/request id and 204 success ignore an illegal body.
#[tokio::test]
async fn revoke_function_posts_exact_body_and_succeeds_on_204() {
let function = sample_revoke_function();
let before = function.clone();
let expected_id = function.id().as_str().to_string();
let conn = Connection::new_with_handler(move |request| {
assert_revoke_request(&request, &expected_id);
// Illegal body on 204 must be ignored; success is status-driven only.
http::Response::builder()
.status(204)
.body(format!(
"{{\"SENSITIVE_REVOKE_BODY_MARKER\":true,\"message\":{REVOKE_SERVER_MESSAGE_MARKER:?},\"name\":{REVOKE_CATALOG_NAME_MARKER:?}}}"
))
.unwrap()
});
conn.revoke_function(&function)
.await
.expect("HTTP 204 must complete revocation");
assert_exact_function(&function, &before);
}
/// Repeated logical revoke calls that each receive 204 both succeed.
///
/// Idempotent public outcome only: each logical call may generate its own
/// internal request id; tests must not assert cross-call id equality.
#[tokio::test]
async fn revoke_function_repeated_204_is_idempotent() {
let function = sample_revoke_function();
let before = function.clone();
let expected_id = function.id().as_str().to_string();
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_ref = attempts.clone();
let conn = Connection::new_with_handler(move |request| {
assert_revoke_request(&request, &expected_id);
attempts_ref.fetch_add(1, Ordering::SeqCst);
http::Response::builder()
.status(204)
.body(String::new())
.unwrap()
});
conn.revoke_function(&function)
.await
.expect("first revoke must succeed on 204");
conn.revoke_function(&function)
.await
.expect("second revoke of an already-revoked Function must also succeed on 204");
assert_eq!(
attempts.load(Ordering::SeqCst),
2,
"two logical revoke calls must issue two exact requests"
);
assert_exact_function(&function, &before);
}
/// One configured 5xx retry then 204 keeps identical request id/body.
#[tokio::test]
async fn revoke_function_retry_preserves_request_id_and_body() {
let function = sample_revoke_function();
let before = function.clone();
let expected_id = function.id().as_str().to_string();
let seen_request_id = Arc::new(OnceLock::new());
let seen_request_id_ref = seen_request_id.clone();
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_ref = attempts.clone();
let conn = Connection::new_with_handler_and_config(
move |request| {
assert_revoke_request(&request, &expected_id);
let request_id = request.headers()["x-request-id"]
.to_str()
.unwrap()
.to_string();
let seen = seen_request_id_ref.get_or_init(|| request_id.clone());
assert_eq!(
&request_id, seen,
"request id must be identical across retries within one logical call"
);
let n = attempts_ref.fetch_add(1, Ordering::SeqCst);
if n == 0 {
http::Response::builder()
.status(500)
.body(format!(
"{REVOKE_SERVER_MESSAGE_MARKER} SENSITIVE_REVOKE_BODY_MARKER"
))
.unwrap()
} else {
http::Response::builder()
.status(204)
.body(String::new())
.unwrap()
}
},
ClientConfig {
retry_config: RetryConfig {
retries: Some(2),
backoff_factor: Some(0.0),
backoff_jitter: Some(0.0),
..Default::default()
},
..Default::default()
},
);
conn.revoke_function(&function)
.await
.expect("revoke must succeed after one retry");
assert_eq!(
attempts.load(Ordering::SeqCst),
2,
"one 5xx then 204 must be exactly two attempts"
);
assert!(seen_request_id.get().is_some());
assert_exact_function(&function, &before);
}
/// Exhausted always-retryable 5xx without explicit error_code surfaces Error::Retry.
///
/// retries=2 is max request failures: exactly two identical attempts, then
/// Retry with request_failures == max_request_failures == 2, zero connect/read
/// failures, the retryable status retained, and a payload-free source chain.
#[tokio::test]
async fn revoke_function_exhausted_retryable_5xx_returns_retry_with_request_counters() {
let function = sample_revoke_function();
let expected_id = function.id().as_str().to_string();
let seen_request_id = Arc::new(OnceLock::new());
let seen_request_id_ref = seen_request_id.clone();
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_ref = attempts.clone();
let body = format!("{REVOKE_SERVER_MESSAGE_MARKER} SENSITIVE_REVOKE_BODY_MARKER");
let conn = Connection::new_with_handler_and_config(
move |request| {
assert_revoke_request(&request, &expected_id);
let request_id = request.headers()["x-request-id"]
.to_str()
.unwrap()
.to_string();
let seen = seen_request_id_ref.get_or_init(|| request_id.clone());
assert_eq!(
&request_id, seen,
"request id must be identical across exhausted retries"
);
attempts_ref.fetch_add(1, Ordering::SeqCst);
http::Response::builder()
.status(500)
.body(body.clone())
.unwrap()
},
ClientConfig {
retry_config: RetryConfig {
// RetryCounter treats `retries` as max request failures, so
// retries=2 yields exactly two transport attempts before Error::Retry.
retries: Some(2),
backoff_factor: Some(0.0),
backoff_jitter: Some(0.0),
..Default::default()
},
..Default::default()
},
);
let err = conn
.revoke_function(&function)
.await
.expect_err("exhausted retryable 5xx must fail");
match &err {
Error::Retry {
request_failures,
max_request_failures,
connect_failures,
read_failures,
status_code,
..
} => {
assert_eq!(*request_failures, 2);
assert_eq!(*max_request_failures, 2);
assert_eq!(
request_failures, max_request_failures,
"request budget must be fully exhausted"
);
assert_eq!(*connect_failures, 0, "5xx must not consume connect budget");
assert_eq!(*read_failures, 0, "5xx must not consume read budget");
assert_eq!(
status_code.map(|s| s.as_u16()),
Some(500),
"retryable status must be retained on Error::Retry"
);
}
other => panic!("exhausted 5xx must surface as Error::Retry, got {other:?}"),
}
assert_eq!(
attempts.load(Ordering::SeqCst),
2,
"retries=2 must make exactly two identical attempts"
);
assert!(seen_request_id.get().is_some());
assert_revoke_payload_free(&err);
}
/// Explicit name_or_function_not_found on a retryable status is terminal.
#[tokio::test]
async fn revoke_function_explicit_name_or_function_not_found_is_terminal_on_retryable_status() {
let function = sample_revoke_function();
let expected_id = function.id().as_str().to_string();
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_ref = attempts.clone();
let body = json!({
"error_code": "name_or_function_not_found",
"message": format!(
"{REVOKE_SERVER_MESSAGE_MARKER} looks_like revoked_function name_conflict"
),
"SENSITIVE_REVOKE_BODY_MARKER": true,
})
.to_string();
let conn = Connection::new_with_handler_and_config(
move |request| {
assert_revoke_request(&request, &expected_id);
attempts_ref.fetch_add(1, Ordering::SeqCst);
http::Response::builder()
.status(503)
.body(body.clone())
.unwrap()
},
ClientConfig {
retry_config: RetryConfig {
retries: Some(3),
backoff_factor: Some(0.0),
backoff_jitter: Some(0.0),
..Default::default()
},
..Default::default()
},
);
let err = conn
.revoke_function(&function)
.await
.expect_err("explicit name_or_function_not_found must fail");
match &err {
Error::Function { code, message } => {
assert_eq!(code.as_str(), "name_or_function_not_found");
assert!(
matches!(code, FunctionErrorCode::NameOrFunctionNotFound),
"expected NameOrFunctionNotFound, got {code:?}"
);
assert_ne!(code.as_str(), "revoked_function");
assert_ne!(code.as_str(), "name_conflict");
assert!(
!message.contains(REVOKE_SERVER_MESSAGE_MARKER),
"Function error message must be sanitized, got {message}"
);
assert!(
!message.contains(REVOKE_FUNCTION_ID),
"Function error message must not echo the FunctionId, got {message}"
);
assert!(
!message.contains(REVOKE_CATALOG_NAME_MARKER),
"Function error message must not echo a catalog name, got {message}"
);
}
other => panic!("expected Error::Function, got {other:?}"),
}
assert_revoke_payload_free(&err);
assert_eq!(
attempts.load(Ordering::SeqCst),
1,
"explicit semantic code must not consume request retries"
);
}
/// Unknown nonempty explicit code is preserved; status/message do not override.
#[tokio::test]
async fn revoke_function_preserves_unknown_explicit_code_despite_status_and_message() {
let function = sample_revoke_function();
let expected_id = function.id().as_str().to_string();
let raw = "enterprise_future_revoke_category_xyz";
let body = json!({
"error_code": raw,
"message": format!(
"{REVOKE_SERVER_MESSAGE_MARKER} name_or_function_not_found revoked_function"
),
"SENSITIVE_REVOKE_BODY_MARKER": true,
})
.to_string();
let conn = Connection::new_with_handler(move |request| {
assert_revoke_request(&request, &expected_id);
http::Response::builder()
.status(409)
.body(body.clone())
.unwrap()
});
let err = conn
.revoke_function(&function)
.await
.expect_err("unknown explicit code must surface");
match &err {
Error::Function { code, message } => {
assert_eq!(code.as_str(), raw);
assert!(
matches!(code, FunctionErrorCode::Unrecognized(_)),
"unknown code must stay Unrecognized, got {code:?}"
);
assert_ne!(code.as_str(), "name_or_function_not_found");
assert_ne!(code.as_str(), "revoked_function");
assert!(
!message.contains(REVOKE_SERVER_MESSAGE_MARKER),
"diagnostic message must be sanitized"
);
}
other => panic!("expected Error::Function, got {other:?}"),
}
assert_revoke_payload_free(&err);
}
/// Missing/empty/null/wrong-type/malformed error_code stays payload-free Http.
#[tokio::test]
async fn revoke_function_missing_or_invalid_error_code_is_payload_free_http() {
let cases: Vec<(&str, u16, String)> = vec![
(
"missing_code_404",
404,
json!({
"message": REVOKE_SERVER_MESSAGE_MARKER,
"SENSITIVE_REVOKE_BODY_MARKER": true,
})
.to_string(),
),
(
"empty_code",
400,
json!({
"error_code": "",
"message": REVOKE_SERVER_MESSAGE_MARKER,
"SENSITIVE_REVOKE_BODY_MARKER": true,
})
.to_string(),
),
(
"wrong_type_code",
400,
json!({
"error_code": 123,
"message": REVOKE_SERVER_MESSAGE_MARKER,
"SENSITIVE_REVOKE_BODY_MARKER": true,
})
.to_string(),
),
(
// Non-retryable status: invalid/null error_code must stay immediate Http.
// Exhausted retryable 5xx is covered by
// revoke_function_exhausted_retryable_5xx_returns_retry_with_request_counters.
"null_code",
400,
json!({
"error_code": null,
"message": REVOKE_SERVER_MESSAGE_MARKER,
"SENSITIVE_REVOKE_BODY_MARKER": true,
})
.to_string(),
),
(
// Non-retryable status: this case proves invalid-code -> Http only.
// Exhausted retryable 5xx without error_code is covered separately
// by revoke_function_exhausted_retryable_5xx_returns_retry_with_request_counters.
"non_json",
400,
format!("not-json {REVOKE_SERVER_MESSAGE_MARKER} SENSITIVE_REVOKE_BODY_MARKER"),
),
];
let mut unexpected = Vec::new();
for (label, status, response_body) in cases {
let function = sample_revoke_function();
let expected_id = function.id().as_str().to_string();
let body_for_handler = response_body.clone();
let conn = Connection::new_with_handler(move |request| {
assert_revoke_request(&request, &expected_id);
http::Response::builder()
.status(status)
.body(body_for_handler.clone())
.unwrap()
});
match conn.revoke_function(&function).await {
Err(err @ Error::Http { .. }) => assert_revoke_payload_free(&err),
Err(Error::Function { .. }) => unexpected.push(format!(
"{label}: must not invent Error::Function without explicit code"
)),
other => unexpected.push(format!("{label}: {other:?}")),
}
}
assert!(
unexpected.is_empty(),
"invalid/missing error_code must stay payload-free Http: {unexpected:?}"
);
}
/// 200/202 are payload-free protocol Http failures, never revoke success.
#[tokio::test]
async fn revoke_function_other_2xx_are_payload_free_http_failures() {
let cases: Vec<(&str, u16, String)> = vec![
(
"200_with_body",
200,
json!({
"ok": true,
"message": REVOKE_SERVER_MESSAGE_MARKER,
"SENSITIVE_REVOKE_BODY_MARKER": true,
"job_id": "must-not-infer-job",
"name": REVOKE_CATALOG_NAME_MARKER,
})
.to_string(),
),
(
"202_empty",
202,
format!("{REVOKE_SERVER_MESSAGE_MARKER} SENSITIVE_REVOKE_BODY_MARKER"),
),
("200_empty", 200, String::new()),
];
let mut unexpected = Vec::new();
for (label, status, response_body) in cases {
let function = sample_revoke_function();
let expected_id = function.id().as_str().to_string();
let body_for_handler = response_body.clone();
let conn = Connection::new_with_handler(move |request| {
assert_revoke_request(&request, &expected_id);
http::Response::builder()
.status(status)
.body(body_for_handler.clone())
.unwrap()
});
match conn.revoke_function(&function).await {
Ok(()) => {
unexpected.push(format!("{label}: must not treat non-204 2xx as success"))
}
Err(err @ Error::Http { .. }) => assert_revoke_payload_free(&err),
Err(Error::Function { .. }) => unexpected.push(format!(
"{label}: must not invent Error::Function from 2xx body"
)),
other => unexpected.push(format!("{label}: {other:?}")),
}
}
assert!(
unexpected.is_empty(),
"200/202 must be payload-free Http failures: {unexpected:?}"
);
}
/// Non-204 success must fail from status alone without reading the body.
///
/// A protocol-invalid HTTP 200 whose body stream fails if read must return
/// payload-free Error::Http with status 200 on exactly one attempt, even when
/// read/request retry budgets are configured above one. Body must not be
/// read and no retry budget may be consumed.
#[tokio::test]
async fn revoke_function_non_204_success_does_not_read_failing_body() {
let function = sample_revoke_function();
let expected_id = function.id().as_str().to_string();
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_ref = attempts.clone();
let conn = Connection::new_with_handler_and_config(
move |request| {
assert_revoke_request(&request, &expected_id);
attempts_ref.fetch_add(1, Ordering::SeqCst);
let stream = futures::stream::once(async {
Err::<bytes::Bytes, _>(std::io::Error::other(
"simulated revoke response body read failure SENSITIVE_REVOKE_BODY_MARKER",
))
});
http::Response::builder()
.status(200)
.body(reqwest::Body::wrap_stream(stream))
.unwrap()
},
ClientConfig {
// Budgets above one must not be consumed: status 200 is terminal
// protocol Http before any body read/retry classification.
retry_config: RetryConfig {
retries: Some(3),
read_retries: Some(3),
connect_retries: Some(3),
backoff_factor: Some(0.0),
backoff_jitter: Some(0.0),
..Default::default()
},
..Default::default()
},
);
let err = conn
.revoke_function(&function)
.await
.expect_err("non-204 success must be protocol Http");
assert_eq!(
attempts.load(Ordering::SeqCst),
1,
"invalid 2xx must not read the body or consume retry budget"
);
match &err {
Error::Http { status_code, .. } => {
assert_eq!(
status_code.map(|s| s.as_u16()),
Some(200),
"protocol Http must retain status 200 from the response alone"
);
}
other => panic!("expected payload-free Error::Http, got {other:?}"),
}
assert_revoke_payload_free(&err);
}
/// Valid local Connection revocation is NotSupported and does not mutate tables.
#[tokio::test]
async fn revoke_function_local_connection_returns_not_supported_without_mutation() {
let dir = tempfile::tempdir().expect("tempdir");
let conn = ConnectBuilder::new(dir.path().to_str().unwrap())
.execute()
.await
.expect("local connect");
let before = conn
.table_names()
.execute()
.await
.expect("table_names before");
assert!(before.is_empty());
let function = sample_revoke_function();
let handle_before = function.clone();
let err = conn
.revoke_function(&function)
.await
.expect_err("local revoke_function must be unsupported");
assert!(
matches!(err, Error::NotSupported { .. }),
"expected NotSupported for local revoke, got {err:?}"
);
assert_exact_function(&function, &handle_before);
let after = conn
.table_names()
.execute()
.await
.expect("table_names after");
assert_eq!(before, after, "unsupported revoke must not mutate tables");
}
/// Database trait seam must return NotSupported without table mutation.
#[tokio::test]
async fn revoke_function_database_trait_local_returns_not_supported_without_mutation() {
let dir = tempfile::tempdir().expect("tempdir");
let conn = ConnectBuilder::new(dir.path().to_str().unwrap())
.execute()
.await
.expect("local connect");
let before = conn
.table_names()
.execute()
.await
.expect("table_names before");
assert!(before.is_empty());
let function = sample_revoke_function();
let handle_before = function.clone();
let err = conn
.database()
.revoke_function(&function)
.await
.expect_err("local Database::revoke_function must be unsupported");
assert!(
matches!(err, Error::NotSupported { .. }),
"expected NotSupported for Database trait revoke, got {err:?}"
);
assert_exact_function(&function, &handle_before);
let after = conn
.table_names()
.execute()
.await
.expect("table_names after");
assert_eq!(
before, after,
"Database-trait unsupported revoke must not mutate tables"
);
}
}
+70 -7
View File
@@ -10,6 +10,10 @@
//! POST `/v1/functions/remove` performs a direct synchronous catalog CAS that
//! unbinds a name when the caller's observed [`Function`] id still matches.
//! This is not a Job, not physical Function deletion, and not revocation.
//!
//! POST `/v1/functions/revoke` performs a direct synchronous administrator
//! catalog set-bit for an exact [`Function`] id. This is not a Job, not name
//! removal, not physical deletion, and not Function mutation.
use reqwest::{RequestBuilder, StatusCode};
use serde::Deserialize;
@@ -23,6 +27,7 @@ use super::retry::RetryCounter;
const LOOKUP_PATH: &str = "/v1/functions/lookup";
const REMOVE_PATH: &str = "/v1/functions/remove";
const REVOKE_PATH: &str = "/v1/functions/revoke";
/// Fixed client diagnostic for [`Error::Function`]. Never carry server text,
/// selector values, or response payload bytes.
@@ -42,6 +47,13 @@ const REMOVE_FUNCTION_ERROR_MESSAGE: &str = "function name removal failed";
/// Fixed client diagnostic for remove protocol / HTTP failures.
const REMOVE_HTTP_ERROR_MESSAGE: &str = "function name removal request failed";
/// Fixed client diagnostic for revoke [`Error::Function`]. Never carry server
/// text, Function id, or response payload bytes.
const REVOKE_FUNCTION_ERROR_MESSAGE: &str = "function revocation failed";
/// Fixed client diagnostic for revoke protocol / HTTP failures.
const REVOKE_HTTP_ERROR_MESSAGE: &str = "function revocation request failed";
/// One exact lookup selector. Exactly one variant is serialized on the wire.
pub enum FunctionLookupSelector {
Name(String),
@@ -80,7 +92,7 @@ struct LookupSuccessResponse {
/// Decision before reading response bytes.
enum BeforeBody<T> {
/// Finish without reading or interpreting any body (HTTP 204 remove CAS).
/// Finish without reading or interpreting any body (HTTP 204 mutations).
Done(Result<T>),
/// Read bytes and continue classification.
ReadBody,
@@ -168,18 +180,69 @@ pub async fn remove_function_name<S: HttpSend>(
});
let req_builder = client.post(REMOVE_PATH).json(&body);
catalog_post_with_retry(
catalog_mutation_with_retry(
client,
req_builder,
REMOVE_HTTP_ERROR_MESSAGE,
REMOVE_FUNCTION_ERROR_MESSAGE,
)
.await
}
/// Revoke an exact Function via POST `/v1/functions/revoke`.
///
/// Direct synchronous administrator catalog set-bit: the wire body is exactly
/// `{"function_id"}` from `function.id`. Only HTTP 204 means the set-bit
/// completed; other 2xx are payload-free protocol [`Error::Http`] and are not
/// retried or body-read. There is no empty-input validation because
/// [`Function`] is already a validated exact handle.
///
/// Retry and explicit-code classification match remove. Sophon owns durable
/// idempotent set-bit semantics; repeated logical calls that each receive 204
/// succeed with no client already-revoked branch.
pub async fn revoke_function<S: HttpSend>(
client: &RestfulLanceDbClient<S>,
function: &Function,
) -> Result<()> {
let body = serde_json::json!({
"function_id": function.id().as_str(),
});
let req_builder = client.post(REVOKE_PATH).json(&body);
catalog_mutation_with_retry(
client,
req_builder,
REVOKE_HTTP_ERROR_MESSAGE,
REVOKE_FUNCTION_ERROR_MESSAGE,
)
.await
}
/// Shared remove/revoke catalog-mutation response classification.
///
/// Exact HTTP 204 succeeds without reading the body. Other 2xx are immediate
/// payload-free [`Error::Http`]. Non-success bodies use explicit nonempty
/// `error_code` as terminal [`Error::Function`], else configured retryable
/// status retry, else payload-free [`Error::Http`]. Each caller supplies its
/// own fixed sanitized messages.
async fn catalog_mutation_with_retry<S: HttpSend>(
client: &RestfulLanceDbClient<S>,
req_builder: RequestBuilder,
http_error_message: &'static str,
function_error_message: &'static str,
) -> Result<()> {
catalog_post_with_retry(
client,
req_builder,
http_error_message,
|status, request_id| {
// Exact HTTP 204 completes the CAS; do not read or interpret any body.
// Exact HTTP 204 completes the mutation; do not read or interpret any body.
if status == StatusCode::NO_CONTENT {
BeforeBody::Done(Ok(()))
} else if status.is_success() {
// Other 2xx are payload-free protocol failures from status alone.
BeforeBody::Done(Err(Error::Http {
source: REMOVE_HTTP_ERROR_MESSAGE.into(),
source: http_error_message.into(),
request_id: request_id.to_string(),
status_code: Some(status),
}))
@@ -192,7 +255,7 @@ pub async fn remove_function_name<S: HttpSend>(
if let Some(code) = explicit_error_code(bytes) {
return CatalogBodyAction::Done(Err(Error::Function {
code,
message: REMOVE_FUNCTION_ERROR_MESSAGE.to_string(),
message: function_error_message.to_string(),
}));
}
@@ -201,7 +264,7 @@ pub async fn remove_function_name<S: HttpSend>(
}
CatalogBodyAction::Done(Err(Error::Http {
source: REMOVE_HTTP_ERROR_MESSAGE.into(),
source: http_error_message.into(),
request_id,
status_code: Some(status),
}))
@@ -210,7 +273,7 @@ pub async fn remove_function_name<S: HttpSend>(
.await
}
/// Shared Function-catalog POST retry loop used by lookup and remove.
/// Shared Function-catalog POST retry loop used by lookup and mutations.
///
/// Sensitive attempt sending logs no body/header selectors. One SDK-generated
/// request id and the exact cloned JSON body are reused across attempts.