- Added rate_limit() method to ErrorResponse for proper HTTP 429 responses - Added check_detailed() to LocalSearchUiRateLimiter returning (allowed, remaining, reset_after) - Implemented IP-based rate limiting in mint_session using Redis or local backend - Extracts client IP from X-Forwarded-For or X-Real-IP headers - Parses rate limit config (e.g., "60/minute" -> limit=60, window=60s) - Returns accurate rate limit info (remaining, reset_in) in session response The rate limit info is now tracked in Redis (miroir:ratelimit:searchui:<ip>) or in local memory, with proper TTL handling. Closes: bf-607z
153 lines
4.9 KiB
Rust
153 lines
4.9 KiB
Rust
//! Meilisearch-compatible error responses.
|
|
//!
|
|
//! Per plan §5, all errors must match the Meilisearch shape:
|
|
//! {"message": "...", "code": "...", "type": "...", "link": "..."}
|
|
|
|
use axum::{
|
|
http::StatusCode,
|
|
response::{IntoResponse, Json, Response},
|
|
};
|
|
use serde::Serialize;
|
|
|
|
/// Meilisearch-compatible error response.
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ErrorResponse {
|
|
/// Human-readable error message.
|
|
pub message: String,
|
|
|
|
/// Machine-readable error code.
|
|
pub code: String,
|
|
|
|
/// Error type category.
|
|
#[serde(rename = "type")]
|
|
pub error_type: String,
|
|
|
|
/// Documentation link.
|
|
pub link: String,
|
|
}
|
|
|
|
impl ErrorResponse {
|
|
/// Create a new error response.
|
|
pub fn new(message: impl Into<String>, code: impl Into<String>) -> Self {
|
|
let message = message.into();
|
|
let code = code.into();
|
|
|
|
// Determine error type from code
|
|
let error_type = if code.starts_with("miroir_") {
|
|
"invalid_request".to_string()
|
|
} else if code.contains("index") {
|
|
"index_creation".to_string()
|
|
} else if code.contains("document") {
|
|
"document".to_string()
|
|
} else {
|
|
"invalid_request".to_string()
|
|
};
|
|
|
|
Self {
|
|
message,
|
|
code,
|
|
error_type,
|
|
link: "https://docs.meilisearch.com/errors".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Create an error for missing primary key.
|
|
pub fn primary_key_required(index: &str) -> Self {
|
|
Self::new(
|
|
format!("Index `{index}` does not have a primary key. A primary key must be declared when creating the index in order to use the document routes."),
|
|
"miroir_primary_key_required",
|
|
)
|
|
}
|
|
|
|
/// Create an error for no quorum.
|
|
pub fn no_quorum(shard_id: u32) -> Self {
|
|
Self::new(
|
|
format!("No replica group met quorum for shard {shard_id}"),
|
|
"miroir_no_quorum",
|
|
)
|
|
}
|
|
|
|
/// Create an error for unavailable shard.
|
|
#[allow(dead_code)]
|
|
pub fn shard_unavailable(shard_id: u32) -> Self {
|
|
Self::new(
|
|
format!("Shard {shard_id} is unavailable"),
|
|
"miroir_shard_unavailable",
|
|
)
|
|
}
|
|
|
|
/// Create an error for reserved field usage.
|
|
#[allow(dead_code)]
|
|
pub fn reserved_field(field: &str) -> Self {
|
|
Self::new(
|
|
format!("Field `{field}` is reserved for internal use and cannot be used in documents",),
|
|
"miroir_reserved_field",
|
|
)
|
|
}
|
|
|
|
/// Create an error for index not found.
|
|
pub fn index_not_found(uid: &str) -> Self {
|
|
Self::new(format!("Index `{uid}` not found."), "index_not_found")
|
|
}
|
|
|
|
/// Create an error for invalid request.
|
|
pub fn invalid_request(message: impl Into<String>) -> Self {
|
|
Self::new(message, "invalid_request")
|
|
}
|
|
|
|
/// Create an error for document not found.
|
|
#[allow(dead_code)]
|
|
pub fn document_not_found(id: &str) -> Self {
|
|
Self::new(
|
|
format!("Document with id `{id}` not found."),
|
|
"document_not_found",
|
|
)
|
|
}
|
|
|
|
/// Create an internal server error.
|
|
pub fn internal_error(message: impl Into<String>) -> Self {
|
|
Self::new(message, "internal_error")
|
|
}
|
|
|
|
/// Create a rate limit error.
|
|
pub fn rate_limited(message: impl Into<String>) -> Self {
|
|
Self {
|
|
message: message.into(),
|
|
code: "miroir_rate_limited".to_string(),
|
|
error_type: "rate_limited".to_string(),
|
|
link: "https://docs.meilisearch.com/errors".to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for ErrorResponse {
|
|
fn into_response(self) -> Response {
|
|
let status = if self.code == "miroir_rate_limited" {
|
|
StatusCode::TOO_MANY_REQUESTS
|
|
} else if self.code == "miroir_no_quorum" || self.code == "miroir_shard_unavailable" {
|
|
StatusCode::SERVICE_UNAVAILABLE
|
|
} else if self.code.contains("not_found") {
|
|
StatusCode::NOT_FOUND
|
|
} else {
|
|
StatusCode::BAD_REQUEST
|
|
};
|
|
|
|
(status, Json(self)).into_response()
|
|
}
|
|
}
|
|
|
|
/// Convert MiroirError to ErrorResponse.
|
|
impl From<miroir_core::MiroirError> for ErrorResponse {
|
|
fn from(err: miroir_core::MiroirError) -> Self {
|
|
match err {
|
|
miroir_core::MiroirError::Config(msg) => Self::new(msg, "invalid_configuration"),
|
|
miroir_core::MiroirError::Topology(msg) => Self::new(msg, "invalid_topology"),
|
|
miroir_core::MiroirError::Routing(msg) => Self::new(msg, "internal_error"),
|
|
miroir_core::MiroirError::Merge(msg) => Self::new(msg, "internal_error"),
|
|
miroir_core::MiroirError::Task(msg) => Self::new(msg, "task_error"),
|
|
miroir_core::MiroirError::Io(err) => Self::new(err.to_string(), "internal_error"),
|
|
miroir_core::MiroirError::Json(err) => Self::new(err.to_string(), "internal_error"),
|
|
_ => Self::new(err.to_string(), "internal_error"),
|
|
}
|
|
}
|
|
}
|