Подготовка к собеседованию по Rust

Вопросы для собеседования бэкенд-разработчика на Rust

15 отобранных вопросов для собеседования по бэкенду на Rust, сгруппированных по уровням квалификации. Используйте их для повторения основ, понимания практических компромиссов и рассуждений на уровне Senior-разработчика о производственных системах.

Начать ИИ-собеседование по RustКредитная карта не требуется. Доступна 1 бесплатная сессия.
Практика технических собеседований на английскомРежим, в котором не носители языка могут потренироваться проходить интервью.

Вопросы для Junior-разработчиков

1Объясните правила владения в Rust и как они формируют практические решения в бэкенд-разработке, такие как клонирование, заимствование и совместное использование состояния приложения.

Rust назначает каждому значению единственного владельца. Перемещение значения, не реализующего типаж `Copy`, передает владение, и значение уничтожается, когда его владелец выходит из области видимости. Код может либо заимствовать данные неизменяемо через общие ссылки, либо изменяемо через исключительную изменяемую ссылку. В бэкенд-коде это определяет, будет ли функция принимать владение, временно заимствовать, клонировать для получения независимого собственного значения, или совместно использовать долговременное состояние через дескрипторы, такие как `Arc`, с использованием синхронизации или других примитивов параллелизма, когда требуется совместное изменяющееся использование.

use std::sync::Arc;

struct AppState {
    service_name: String,
}

async fn handler(state: Arc<AppState>, request_id: String) -> String {
    // Borrow: no ownership transfer of the String inside state.
    let name: &str = &state.service_name;

    // Clone only if an owned independent value is needed.
    let owned_name = name.to_owned();

    format!("{owned_name}:{request_id}")
}

fn configure(state: Arc<AppState>) {
    // Cloning Arc increments the reference count; it does not clone AppState.
    let state_for_route = Arc::clone(&state);
    let _ = state_for_route;
}
Попробовать ответить на вопрос AI-тренеру

2В чем разница между заимствованием (`borrowing`) с помощью `&T` и `&mut T`, и как эти правила влияют на сигнатуры API (программного интерфейса) и обработчиков?

`&T` — это разделяемая ссылка: она позволяет доступ только для чтения, и множество разделяемых ссылок на одно и то же значение могут быть активны одновременно. `&mut T` — это исключительная изменяемая ссылка: она позволяет изменять данные, но пока она активна, никакие другие разделяемые или изменяемые ссылки на то же значение не могут быть использованы. Эти правила формируют API, делая функции только для чтения, принимающими `&T` или более узкие заимствованные типы, такие как `&str`, в то время как изменяющие функции принимают `&mut T`, получают владение или используют внутреннюю изменяемость (`interior mutability`)/синхронизацию. Бэкенд-обработчики обычно избегают прямого доступа `&mut` к общему состоянию приложения при одновременных запросах и вместо этого используют разделяемые дескрипторы плюс синхронизацию или другие паттерны владения.

struct User {
    name: String,
    login_count: u64,
}

fn display_name(user: &User) -> &str {
    &user.name
}

fn record_login(user: &mut User) {
    user.login_count += 1;
}

fn main() {
    let mut user = User { name: "Ada".into(), login_count: 0 };

    let name = display_name(&user); // shared borrow for reading
    println!("{name}");

    record_login(&mut user); // exclusive mutable borrow for mutation
}
Попробовать ответить на вопрос AI-тренеру

3Как работает `Box` и какие проблемы решает выделение памяти в куче с помощью `Box`?

`Box<T>` — это владеющий умный указатель, значение `T` которого хранится в куче, в то время как дескриптор `Box` представляет собой значение фиксированного размера, похожее на указатель. Он обладает единоличным владением и удаляет/освобождает выделение памяти в куче, когда выходит из области видимости. `Box` обеспечивает косвенную адресацию, что помогает при работе с большими значениями, рекурсивными типами, которым требуется известный размер, значениями с динамическим размером и объектами типажа (trait objects), такими как `Box<dyn Trait>`. Перемещение `Box` передает владение выделенной памятью без перемещения или копирования самого значения, хранящегося в куче.

enum List {
    Cons(i32, Box<List>),
    Nil,
}

let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
Попробовать ответить на вопрос AI-тренеру

4Опишите `Result` и `Option` в Rust, а также основные идиоматические способы работы с отсутствием значений и операциями, которые могут завершиться ошибкой.

`Option<T>` представляет либо `Some(T)`, либо `None` и используется для ожидаемого отсутствия значения. `Result<T, E>` представляет либо `Ok(T)`, либо `Err(E)` и используется для операций, которые могут завершиться ошибкой, когда информация об ошибке важна. Идиоматическая обработка включает `match`, `if let`/`let else`, комбинаторы, такие как `map`, `and_then`, `ok_or`, `unwrap_or` и `unwrap_or_else`, а также оператор `?` для раннего распространения ошибок из функций, возвращающих совместимые типы `Option` или `Result`. Методы `unwrap` и `expect` лучше всего использовать для инвариантов, тестов, прототипов или невосстановимых ситуаций, а не для обычных восстановимых ошибок бэкенда.

fn parse_port(s: Option<&str>) -> u16 {
    s.and_then(|v| v.parse::<u16>().ok())
     .unwrap_or(8080)
}

assert_eq!(parse_port(Some("3000")), 3000);
assert_eq!(parse_port(None), 8080);
Попробовать ответить на вопрос AI-тренеру

5Как работает оператор `?` в Rust и что позволяет преобразовывать между различными типами ошибок?

Оператор `?` является сокращенной формой для распространения ошибок (failure propagation). Для `Result` выражение `expr?` возвращает значение `Ok` и продолжает выполнение, либо немедленно выходит из текущей функции со значением `Err`; для `Option` оно возвращает значение `Some` или `None`. Внешняя функция должна возвращать совместимый тип. Для `Result` различные типы ошибок могут быть скомпонованы, потому что исходная ошибка преобразуется в тип ошибки функции с использованием преобразования в стиле `From`/`Into`, часто реализуемого вручную или генерируемого с помощью вспомогательных крейтов, таких как `thiserror`.

use std::{fs, io, num::ParseIntError};

#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(ParseIntError),
}

impl From<io::Error> for AppError {
    fn from(e: io::Error) -> Self { AppError::Io(e) }
}
impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> Self { AppError::Parse(e) }
}

fn read_number(path: &str) -> Result<i32, AppError> {
    let s = fs::read_to_string(path)?; // io::Error -> AppError
    let n = s.trim().parse::<i32>()?;  // ParseIntError -> AppError
    Ok(n)
}
Попробовать ответить на вопрос AI-тренеру

6Объясните сопоставление с образцом (pattern matching) для перечислений (enums) и то, как исчерпывающее сопоставление улучшает корректность доменной логики и API.

Перечисления (enums) моделируют значение, которое может быть одним из фиксированного набора вариантов, и эти варианты могут содержать данные. Оператор `match` выбирает ветвь по варианту и может деструктурировать данные внутри. Rust проверяет, что сопоставления перечислений являются исчерпывающими, если не используется шаблон-заглушка (wildcard) или обобщающий шаблон (catch-all). Это улучшает корректность доменной логики и API, потому что при добавлении нового варианта состояния или ответа компилятор может заставить код, который сопоставляет его, решить, как обрабатывать новый случай, вместо того чтобы молча игнорировать его.

enum PaymentStatus {
    Pending,
    Paid { receipt_id: String },
    Failed(String),
}

fn message(status: PaymentStatus) -> String {
    match status {
        PaymentStatus::Pending => "Payment is pending".to_string(),
        PaymentStatus::Paid { receipt_id } => format!("Paid, receipt {receipt_id}"),
        PaymentStatus::Failed(reason) => format!("Payment failed: {reason}"),
    }
}
Попробовать ответить на вопрос AI-тренеру

7Что такое обертки-ньютайпы (newtype wrappers) и как они повышают типобезопасность для идентификаторов, денег, токенов и секретов?

Обертка-ньютайп (newtype wrapper) — это отдельный тип Rust, часто кортежная структура с одним полем, оборачивающая существующее представление, например, `struct UserId(Uuid);` или `struct AccessToken(String);`. Она повышает типобезопасность, потому что компилятор не будет путать семантически различные значения, которые имеют один и тот же базовый тип, такие как `UserId` против `OrderId`, центы против долларов, или необработанные строки против валидированных токенов. Ньютайпы также могут централизовать правила конструирования и валидации, а также управлять такими трейтами, как `Display`, `Debug`, `Serialize`, или поведением для сокрытия секретов.

use uuid::Uuid;

#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
struct UserId(Uuid);

#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
struct OrderId(Uuid);

struct SecretToken(String);

impl std::fmt::Debug for SecretToken {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("SecretToken(**redacted**)")
    }
}

fn load_user(id: UserId) {
    // query by user id
}

fn example(user_id: UserId, order_id: OrderId) {
    load_user(user_id);
    // load_user(order_id); // does not compile: expected UserId, found OrderId
}
Попробовать ответить на вопрос AI-тренеру

Вопросы для Middle-разработчиков

8Как работает трейт `Drop`, включая порядок вызова `drop`, очистку по принципу RAII и ограничения, связанные с асинхронной очисткой?

`Drop` — это детерминированный хук очистки в Rust. Когда владеемое значение уничтожается, например, при выходе из области видимости, Rust автоматически вызывает его метод `drop(&mut self)`, если оно реализует `Drop`, а затем уничтожает его поля. Это поддерживает `RAII` (Resource Acquisition Is Initialization — захват ресурса есть инициализация): ресурсы, такие как файлы, сокеты, блокировки, транзакции, разрешения или буферы, привязаны к владеющим значениям/объектам-хранителям и освобождаются, когда эти значения уничтожаются. Локальные переменные уничтожаются в обратном порядке создания; для структуры, реализующей `Drop`, метод `drop` структуры выполняется до уничтожения ее полей, а поля уничтожаются в порядке объявления. `Drop::drop` является синхронным и не может быть `async` или ожидаемым (`awaited`), поэтому для корректной асинхронной очистки обычно требуется явный асинхронный метод `close`/`shutdown`/`flush` или другой подход; `Drop` может выполнять только синхронную очистку или очистку по мере возможности.

struct Guard(&'static str);

impl Drop for Guard {
    fn drop(&mut self) {
        println!("drop {}", self.0);
    }
}

struct Pair {
    first: Guard,
    second: Guard,
}

impl Drop for Pair {
    fn drop(&mut self) {
        println!("drop Pair");
    }
}

fn main() {
    let _a = Guard("a");
    let _pair = Pair {
        first: Guard("first"),
        second: Guard("second"),
    };
    let _b = Guard("b");
}
Попробовать ответить на вопрос AI-тренеру

9Объясните времена жизни и почему явные аннотации времени жизни иногда требуются в API (Application Programming Interface) Rust.

Время жизни описывает, как долго ссылка остаётся действительной, позволяя компилятору отклонять висячие ссылки. Аннотации времени жизни — это ограничения на этапе компиляции, которые выражают отношения между ссылками; они не продлевают время жизни базовых данных и не добавляют поведения во время выполнения. Многие простые сигнатуры обрабатываются с помощью элизии времени жизни, но явные аннотации необходимы, когда компилятор не может вывести, как связаны входные и выходные ссылки, когда типы хранят ссылки, или когда API необходимо выразить общие ограничения заимствования.

fn longest<'a>(left: &'a str, right: &'a str) -> &'a str {
    if left.len() >= right.len() { left } else { right }
}

struct UserView<'a> {
    name: &'a str,
}

fn main() {
    let a = String::from("short");
    let b = String::from("longer");
    let result = longest(&a, &b);
    let view = UserView { name: result };
    println!("{}", view.name);
}
Попробовать ответить на вопрос AI-тренеру

10Что означает время жизни `'static` и как оно взаимодействует с запущенными задачами (spawned tasks) и дизайном API бэкенда?

Ограничение `'static` для ссылки означает, что данные, на которые ссылаются, действительны в течение всего срока выполнения программы, как, например, строковые литералы или `static`-элементы. Ограничение типа `T: 'static` означает, что значения типа `T` не содержат заимствованных ссылок, не являющихся `'static'`, поэтому они могут существовать произвольное время жизни; это не означает, что само значение должно существовать вечно или не может быть отброшено. Запущенные задачи часто требуют фьючерсов `'static'`, потому что исполнитель может продолжать их выполнение после возврата из фрейма стека, запустившего задачу. В коде бэкенда это обычно означает перемещение владеемых данных в задачу с помощью `async move`, использование владеемых типов или клонирование общих дескрипторов (handles), таких как `Arc`, вместо заимствования локальных переменных. API должны использовать ограничения `'static` для хранимых обратных вызовов, фоновых задач и долгоживущего состояния, но избегать излишних ограничений `'static` для краткосрочных заимствованных операций.

use std::sync::Arc;

struct AppState {
    name: String,
}

fn spawn_background(state: Arc<AppState>, user_id: String) {
    tokio::spawn(async move {
        // The task owns an Arc handle and a String, so it does not borrow this function's stack.
        println!("{}:{user_id}", state.name);
    });
}
Попробовать ответить на вопрос AI-тренеру

11Опишите, как Rust использует трейты для полиморфизма, и сравните `impl Trait`, дженерики и `dyn Trait`.

Rust использует трейты для выражения общего поведения. Дженерик-ограничения, такие как `fn f<T: Trait>(x: T)`, обычно используют статическую диспетчеризацию через мономорфизацию. `impl Trait` в позиции аргумента — это в основном сокращение для анонимного дженерик-параметра. `impl Trait` в позиции возвращаемого значения скрывает один конкретный возвращаемый тип, выбранный функцией. `dyn Trait` — это объект-трейт, используемый за указателем, такой как `&dyn Trait` или `Box<dyn Trait>`; он использует динамическую диспетчеризацию через таблицу виртуальных методов (vtable) и поддерживает стирание типов во время выполнения / гетерогенные значения с учётом ограничений объектной безопасности.

trait Render { fn render(&self) -> String; }

struct Html;
struct Json;

impl Render for Html { fn render(&self) -> String { "html".into() } }
impl Render for Json { fn render(&self) -> String { "json".into() } }

fn render_generic<T: Render>(x: &T) -> String { x.render() } // static dispatch
fn render_impl_arg(x: &impl Render) -> String { x.render() } // anonymous generic
fn make_renderer() -> impl Render { Html } // one hidden concrete type
fn render_dyn(x: &dyn Render) -> String { x.render() } // dynamic dispatch

fn main() {
    let items: Vec<Box<dyn Render>> = vec![Box::new(Html), Box::new(Json)];
    for item in items { println!("{}", render_dyn(item.as_ref())); }
}
Попробовать ответить на вопрос AI-тренеру

12Что такое ассоциированные типы в трейтах, и когда они предпочтительнее параметров обобщенных типов?

Ассоциированные типы — это именованные плейсхолдеры типов, объявленные внутри трейта и конкретизируемые каждой реализацией, например `Iterator::Item`. Они предпочтительнее, когда тип является частью контракта трейта, и каждый реализатор имеет один естественный выбор для него. Параметры обобщенных типов предпочтительнее, когда вызывающий код должен выбирать тип или когда одному и тому же реализатору может потребоваться несколько реализаций для различных вариантов типов.

trait Repository {
    type Entity;
    fn get(&self, id: u64) -> Option<Self::Entity>;
}

struct User;
struct UserRepo;

impl Repository for UserRepo {
    type Entity = User;
    fn get(&self, _id: u64) -> Option<Self::Entity> { Some(User) }
}

fn load<R: Repository>(repo: &R) -> Option<R::Entity> {
    repo.get(1)
}
Попробовать ответить на вопрос AI-тренеру

Вопросы для Senior-разработчиков

13Как бы вы реализовали паттерн "транзакционный исходящий ящик" (Transactional Outbox) в сервисе на Rust?

Для реализации паттерна Transactional Outbox в сервисе на Rust необходимо атомарно записывать обновления доменных данных и полезные нагрузки исходящих событий в рамках одной транзакции базы данных (например, используя `sqlx::Transaction`). Таблица `outbox` хранит целевой топик, полезную нагрузку и статус или временную метку создания. Фоновый асинхронный воркер (или процесс Change Data Capture (CDC), такой как Debezium) затем опрашивает или стримит ожидающие события и публикует их в брокер сообщений (например, Kafka, RabbitMQ или NATS). При использовании опрашивающего воркера в Rust (например, в цикле `tokio::spawn`), применение `SELECT ... FOR UPDATE SKIP LOCKED` позволяет нескольким экземплярам сервиса параллельно получать и отправлять записи из outbox без конфликтов блокировок. Как только доставка брокером подтверждена, воркер обновляет статус записи в outbox или удаляет строку. Поскольку повторные попытки по сети могут привести к доставке как минимум один раз (at-least-once delivery), нижестоящие потребители должны быть спроектированы так, чтобы обрабатывать сообщения идемпотентно.

use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;

pub async fn create_user_with_outbox(
    mut tx: Transaction<'_, Postgres>,
    username: &str,
    email: &str,
) -> Result<(), sqlx::Error> {
    let user_id = Uuid::new_v4();
    sqlx::query!(
        "INSERT INTO users (id, username, email) VALUES ($1, $2, $3)",
        user_id, username, email
    )
    .execute(&mut *tx)
    .await?;

    let payload = serde_json::json!({"event": "UserCreated", "user_id": user_id, "email": email});
    sqlx::query!(
        "INSERT INTO outbox_events (id, topic, payload, status) VALUES ($1, $2, $3, 'PENDING')",
        Uuid::new_v4(),
        "user_events",
        payload
    )
    .execute(&mut *tx)
    .await?;

    tx.commit().await?;
    Ok(())
}

pub async fn run_outbox_relay(pool: PgPool) {
    loop {
        let mut tx = match pool.begin().await { Ok(t) => t, Err(_) => continue };
        let rows = sqlx::query!(
            "SELECT id, topic, payload FROM outbox_events WHERE status = 'PENDING' LIMIT 50 FOR UPDATE SKIP LOCKED"
        )
        .fetch_all(&mut *tx)
        .await;

        if let Ok(events) = rows {
            for event in events {
                // publish_to_broker(&event.topic, &event.payload).await;
                let _ = sqlx::query!("UPDATE outbox_events SET status = 'PROCESSED' WHERE id = $1", event.id)
                    .execute(&mut *tx)
                    .await;
            }
            let _ = tx.commit().await;
        }
        tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
    }
}
Попробовать ответить на вопрос AI-тренеру

14Как бы вы спроектировали многоэтапную операцию, которая обновляет базу данных и вызывает внешний API (интерфейс прикладного программирования)?

Проектирование многоэтапной операции, включающей запись в базу данных и вызов внешнего API, требует управления частичными сбоями и сетевыми разделами без опоры на двухфазный коммит (2PC): 1. **Сначала сохранить состояние / намерение**: Записать начальное состояние (например, `PENDING` или `INITIATED`) и необходимую полезную нагрузку внутри локальной транзакции базы данных. Зафиксировать эту транзакцию перед инициированием внешнего сетевого вызова, чтобы избежать удержания блокировок базы данных и слотов пула соединений во время медленного сетевого ввода-вывода (I/O). 2. **Идемпотентный внешний запрос**: Вызвать внешний API, используя детерминированный ключ идемпотентности (например, полученный из UUID (Universally Unique Identifier) операции). Это гарантирует, что повторные попытки не приведут к дублирующим побочным эффектам в реальном мире (например, двойному списанию средств). 3. **Переход состояния и обработка тайм-аутов**: Если вызов API возвращает успешный ответ, обновить запись в базе данных до `COMPLETED`. Если он терпит неудачу окончательно, пометить его как `FAILED` и выполнить компенсирующие действия. Если вызов истекает по времени или возвращает сетевую ошибку, пометить запись как `INDETERMINATE` / `REQUIRES_RECONCILIATION` и позволить фоновому обработчику согласовать состояние, запросив конечную точку статуса провайдера или повторив попытку с тем же ключом идемпотентности.

use uuid::Uuid;

pub async fn process_external_charge(pool: &sqlx::PgPool, user_id: Uuid, amount: i64) -> Result<(), String> {
    let payment_id = Uuid::new_v4();
    let idempotency_key = format!("pay_{}", payment_id);

    // 1. Commit intent locally before making external network calls
    sqlx::query!(
        "INSERT INTO payments (id, user_id, amount, status, idempotency_key) VALUES ($1, $2, $3, 'PENDING', $4)",
        payment_id, user_id, amount, idempotency_key
    )
    .execute(pool)
    .await
    .map_err(|e| e.to_string())?;

    // 2. Call external API with timeout and idempotency key
    let client = reqwest::Client::new();
    let api_result = client.post("https://api.payment.com/v1/charges")
        .header("Idempotency-Key", &idempotency_key)
        .json(&serde_json::json!({ "amount": amount }))
        .timeout(std::time::Duration::from_secs(5))
        .send()
        .await;

    // 3. Update state based on outcome; handle timeouts via reconciliation
    match api_result {
        Ok(resp) if resp.status().is_success() => {
            sqlx::query!("UPDATE payments SET status = 'SUCCESS' WHERE id = $1", payment_id)
                .execute(pool)
                .await
                .map_err(|e| e.to_string())?;
        }
        Ok(_) => {
            sqlx::query!("UPDATE payments SET status = 'FAILED' WHERE id = $1", payment_id)
                .execute(pool)
                .await
                .map_err(|e| e.to_string())?;
        }
        Err(_) => {
            sqlx::query!("UPDATE payments SET status = 'REQUIRES_RECONCILIATION' WHERE id = $1", payment_id)
                .execute(pool)
                .await
                .map_err(|e| e.to_string())?;
        }
    }
    Ok(())
}
Попробовать ответить на вопрос AI-тренеру

15Как бы вы спроектировали многопользовательский (multi-tenant) бэкенд на Rust со строгой изоляцией данных и динамической маршрутизацией?

Проектирование многопользовательского (multi-tenant) бэкенда на Rust включает три ключевые области: идентификацию арендатора, стратегию изоляции данных и динамическую маршрутизацию. 1. **Извлечение арендатора и распространение контекста (Tenant Extraction & Context Propagation):** HTTP-промежуточное ПО (middleware) (например, в Axum или Actix-web) определяет идентификатор арендатора из утверждений JWT (JSON Web Token), субдоменов или заголовков. Оно конструирует строго типизированный `TenantContext`, хранящийся в расширениях запроса. Обработчики используют экстракторы типов Rust, чтобы обеспечить, что бизнес-логика не может быть выполнена без действительного контекста арендатора. 2. **Стратегия изоляции данных:** * **Общая база данных с безопасностью на уровне столбцов / строк (RLS):** Все арендаторы используют общие таблицы со столбцом `tenant_id`. Изоляция обеспечивается через RLS PostgreSQL с использованием сессионных переменных на уровне соединения (например, `SET LOCAL app.tenant_id = $1`) или оберток построителя запросов. * **Схема на арендатора (Schema-per-Tenant):** Арендаторы используют общий экземпляр базы данных, но имеют изолированные схемы; маршрутизация корректирует путь поиска для каждого соединения. * **База данных на арендатора (Database-per-Tenant):** Арендаторы имеют отдельные базы данных. Сервис поддерживает реестр пулов (например, `Arc<DashMap<TenantId, PgPool>>` или кеш пула по LRU-алгоритму) для динамического разрешения пулов соединений. 3. **Предотвращение утечек (Leak Prevention):** Чтобы избежать межарендной контаминации в асинхронных приложениях Tokio, контекст арендатора должен явно передаваться через границы задач, а не храниться в глобальном состоянии или потоково-локальном хранилище (`thread_local!`), что нарушается при миграции между асинхронными рабочими потоками.

use axum::{extract::{FromRequestParts, State}, http::request::Parts, async_trait};
use std::sync::Arc;
use dashmap::DashMap;
use sqlx::PgPool;

#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct TenantId(pub String);

#[derive(Clone)]
pub struct AppState {
    pub pool_registry: Arc<DashMap<TenantId, PgPool>>,
}

#[derive(Clone)]
pub struct TenantContext {
    pub tenant_id: TenantId,
    pub pool: PgPool,
}

#[async_trait]
impl FromRequestParts<AppState> for TenantContext {
    type Rejection = (axum::http::StatusCode, &'static str);

    async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
        let tenant_header = parts.headers.get("X-Tenant-ID")
            .and_then(|v| v.to_str().ok())
            .ok_or((axum::http::StatusCode::BAD_REQUEST, "Missing tenant ID"))?;
        
        let tenant_id = TenantId(tenant_header.to_string());
        
        let pool = match state.pool_registry.get(&tenant_id) {
            Some(p) => p.value().clone(),
            None => return Err((axum::http::StatusCode::NOT_FOUND, "Tenant database pool not found")),
        };

        Ok(TenantContext { tenant_id, pool })
    }
}
Попробовать ответить на вопрос AI-тренеру