1. How does an Actix Web handler get its inputs?
Through extractors: any handler argument whose type implements FromRequest is built from the request before the handler runs. A handler is an async function that takes up to 16 extractors and returns something that implements Responder.
The extractors guide lists the built-in ones: Path, Query, Json, Form, Bytes, String, Payload, Header, Data, HttpRequest and more.
use actix_web::{post, web, HttpResponse, Responder};
use serde::Deserialize;
#[derive(Deserialize)]
struct NewComment {
body: String,
}
#[derive(Deserialize)]
struct Paging {
notify: Option<bool>,
}
#[post("/posts/{post_id}/comments")]
async fn create_comment(
path: web::Path<u64>,
query: web::Query<Paging>,
payload: web::Json<NewComment>,
) -> impl Responder {
let post_id = path.into_inner();
let notify = query.notify.unwrap_or(false);
HttpResponse::Created().json(serde_json::json!({
"post_id": post_id,
"notify": notify,
"length": payload.body.len(),
}))
}
A strong candidate adds that only one extractor can consume the body. Json, Form, Bytes, String and Payload all read the same stream, so declaring two of them means the second sees an empty body.
A weak answer treats extractors as magic and cannot say what happens when one fails.
2. What status does GET /users/abc return here?
#[get("/users/{id}")]
async fn get_user(id: web::Path<u32>) -> impl Responder {
format!("user {}", id.into_inner())
}
404 Not Found. When Path fails to deserialize a segment, its default error handler wraps the error in ErrorNotFound, so the client sees the same status as an unknown route. A failed Query or Json extraction returns 400 Bad Request instead.
Many candidates guess 400. The follow-up is whether 404 is right for your API.
It hides a client bug behind a "no such page" response, so teams that want a 400 with a JSON body register a web::PathConfig with a custom error_handler through app_data.
A strong candidate also says the handler never runs, so logging inside it will not show the failure.
3. What are the default body size limits, and how do you change them?
The defaults come from three config types, each read from app_data:
JsonConfig: 2 MiB (2,097,152 bytes), plus an optional content type check and error handler.
PayloadConfig: 256 KiB, used by Bytes and String.
FormConfig: 32 KiB since 4.18.2, up from 16 KiB.
App::new()
.app_data(web::JsonConfig::default().limit(64 * 1024))
.service(
web::scope("/uploads")
.app_data(web::PayloadConfig::new(20 * 1024 * 1024))
.route("", web::post().to(upload)),
)
Config set on a scope or resource overrides the app-level value for routes under it, so a large-upload endpoint does not force a high limit on every JSON route.
Since 4.18.2 the limits also apply after decompression, which closes the case where a small gzip or zstd body expanded past the limit inside the decoder.
A candidate who streams large uploads should reach for web::Payload and enforce a limit chunk by chunk, or call Payload::to_bytes_limited.
4. Find the bug: why do malformed bodies get a 200?
#[post("/events")]
async fn ingest(event: Option<web::Json<Event>>) -> impl Responder {
if let Some(event) = event {
store(event.into_inner()).await;
}
HttpResponse::Ok().finish()
}
Option<T> turns any extraction error into None. A body with a missing field, the wrong content type or a size overflow is silently dropped, and the client sees success. Events disappear with no log line and no error rate to alert on.
The fix depends on intent. If the body is required, take web::Json<Event> directly and let the extractor return 400.
If the handler needs to react to the error, take Result<web::Json<Event>, actix_web::Error> and log or map the error explicitly.
A strong candidate reserves Option for inputs that are optional, such as an Option<web::Header<...>>, and never wraps a body extractor in it.
5. How do you write a custom extractor?
Implement FromRequest with an error type and a future. For synchronous work such as reading a header, use std::future::Ready; for async work such as a database lookup, return a boxed future.
use std::future::{ready, Ready};
use actix_web::{dev::Payload, error::ErrorUnauthorized, Error, FromRequest, HttpRequest};
pub struct ApiKey(pub String);
impl FromRequest for ApiKey {
type Error = Error;
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
let key = req
.headers()
.get("x-api-key")
.and_then(|v| v.to_str().ok())
.map(|s| ApiKey(s.to_owned()));
ready(key.ok_or_else(|| ErrorUnauthorized("missing api key")))
}
}
Any handler that declares ApiKey now rejects requests without the header before its body runs.
A strong candidate compares this with doing the same check in middleware: an extractor is opt-in per handler and gives the handler a typed value, while middleware applies to a whole scope and cannot hand a typed value to the handler except through request extensions.
They also avoid reading the body in a custom extractor unless that is its whole job.
6. How does routing work, and what happens when two routes overlap?
Routes are registered with macros (#[get], #[post], #[route]) and .service(), or with the builder API: App::route, web::resource and web::scope. Path segments use {name} syntax, and {tail:.*} matches the rest of the path.
The URL dispatch docs describe matching as first match wins in registration order.
That order is the trap. A resource for /users/{id} registered before /users/me swallows the second, and me then fails to parse as an integer and returns 404 (question 2).
Guards such as guard::Header or guard::Host can disambiguate resources with the same path.
Since 4.18.2, when a path matches but every method guard rejects the request, the built-in default returns 405 with an Allow header instead of 404, which also applies to macro routes.
7. What can a handler return?
Anything that implements Responder: HttpResponse, String, &'static str, web::Json<T>, web::Html (added in 4.8), Either<L, R>, and Result<T, E> where E converts into actix_web::Error.
Since 4.10, Result<(), E> is a responder too, and Ok(()) becomes 204 No Content.
The Responder trait has customize(), which lets a handler change the status or add headers without building an HttpResponse by hand:
async fn create() -> impl Responder {
web::Json(Created { id: 42 })
.customize()
.with_status(StatusCode::CREATED)
.insert_header(("location", "/items/42"))
}
A weak answer returns HttpResponse everywhere and serializes JSON by hand. A strong one picks typed responders so the handler signature documents what it returns.