Repository navigation
v0.5.0
Breaking
Task #1072 (error projection layers) is the 0.5.0 break: one typed Rejection
per failure, projected once per route through an error envelope. Each item is
detailed under Added; the step-by-step developer migration is
docs/migration/error-projection.md.
- Breaking: extractor rejections render as the envelope's JSON (task
#1072). Every extractor rejection —Json/Path/Query/Form/header/
multipart failures, a missing or unsupportedcontent-type, a 413 body
limit — is now projected through the route's envelope, so with the default
HttpErrorit answers{"error": "..."}(application/json) where 0.4
returned axum'stext/plainbody. Status codes are unchanged;HttpError's
own bodies stay byte-equal to 0.4. Clients parsing those plain-text bodies
must be updated. - Breaking:
HttpError::Validationis a struct variant
Validation { status: StatusCode, response: ValidationErrorResponse }(was
the tupleValidation(ValidationErrorResponse)). Build it with
HttpError::validation(response)(status 400); match with
HttpError::Validation { response, .. }. The carried status keeps an
envelope'sstatus_of(Validation)remap (e.g. 422) through
From<Rejection> for HttpErrorand back. The default 400 body is unchanged. - Breaking: guards return
Result<(), Rejection>(task #1072, phase P1).
Guard<I>::checkandPreAuthGuard::checkno longer build aResponse;
they return aRejection(Rejection::forbidden(..),
Rejection::unauthenticated(), or a typed error through?/From) that the
route projects through its envelope.GuardContext::parse_path_paramreturns
Result<T, GuardError>. Pre-auth guards are the first step of the route's
entry fn, not a middleware layer. - Breaking:
ManagedResource::Error: Into<Rejection>(was
Into<Response>). Acquire/finalize failures are projected like any other
rejection;HttpErrorstill qualifies.ManagedErr<E>still requires
E: Into<Rejection>; an error that was onlyInto<Response>must be mapped
(e.g. toHttpError) first. - Breaking:
ParamsRejectionFormatandserver.params-rejection-format
removed.#[derive(Params)]failures always render through the route's
envelope (defaultHttpError, same body as the previous default). - Breaking:
Via<T, M>removed. The generated handlers resolve bean-backed
extractors inline; hand-written handlers keepBeanExtract<T, I>. The
ViaAxumbridge now requires the axum rejection to convertInto<Rejection>
(every axum built-in does). - Breaking: the gRPC guard family is gone (task #1072, phase P3).
GrpcGuard,GrpcGuardContext,GrpcRolesGuardandGrpcRoleBasedIdentity
are removed fromr2e-grpc(and its prelude); writeGuard<I>impls and put
them on the method or impl block with#[guard]— they now work on HTTP,
gRPC and MCP alike. ManualGrpcIdentityExtractor::extract_claimswiring
still compiles but is no longer needed: use an#[inject(identity)]
parameter. - Breaking:
r2e_mcp::guard::guard_response_to_errorremoved; the
generated tool code usesMcpError::from(rejection). - Breaking:
RouteInfo.has_authremoved (task #1072, phase P2) in favour
ofrejection_kinds;RouteInfoliterals need the two new fields. The
OpenAPI spec no longer hardcodes 401/403/500/400: default-envelope routes
now list 415/413/422 for JSON bodies and 400 for path/query parameters
(what the runtime already answered), and theFieldErrorcomponent is gone
(ValidationErrorResponse.detailsitems are inline).RejectionKindis
Serialize. - Breaking:
ParamErrorgainslocation: ParamLocation(Path/Query/
Header), so a#[derive(Params)]failure converts into the right
RejectionKind. Code that builds aParamErrorby struct literal or
destructures it exhaustively (let ParamError { message } = e) must name
the new field; hand-writtenPrefixedExtractimpls (below) now return it. - Breaking:
PrefixedExtract::extract_prefixedreturns
Result<Self, ParamError>(wasResult<Self, Response>). A hand-written
impl (nested#[derive(Params)]support) returns aParamErrorwith its
locationinstead of a rendered response. - Breaking:
#[derive(Params)]rejects withParamError—
<T as FromRequestParts<S>>::RejectionisParamError(wasResponse).
Code naming the rejection type or calling.into_response()on it still
works (ParamError: IntoHttpResponse); code matching it as aResponse
must convert first. - Breaking:
TypedMultipart<T>rejects withMultipartError
(<TypedMultipart<T> as FromRequest<S>>::RejectionwasResponse). It
converts intoRejectionby its variant; hand-written handlers that
forwarded theResponsecall.into_response()(orRejection::from). - Breaking: a
Form<T>body that does not deserialize isInvalidBody
(422), no longerInvalidForm; the status axum carries (422) is
unchanged, soHttpErrorresponses do not move, but an envelope remapping
InvalidFormno longer sees it. A query-string form (GET/HEAD) stays
InvalidForm(400).RawForm: a wrong content type was already
UnsupportedMediaType; the other failures, previously allInvalidForm,
now map toPayloadTooLarge(413),BodyRead(unreadable body) or the
kind matching their status. - Breaking: OpenAPI documents the panic 500 on every route through the
application envelope (ErrorProjector, defaultHttpError) — what the
catch-panic layer answers — merged as ananyOfwhen the route's envelope
shares the status. Optional identities (Option<identity>field or
parameter) now document 401 (an invalid token still fails), SSE routes
document theirQuery/Path/Form/garde failures and WS routes their
Query/Path/garde failures (WS extracts request parts only, soForm<T>
does not apply there), and aForm<T>body documents 415/413/400/422
instead of 400. - Breaking: unknown routes and wrong methods answer JSON (task #1072, phase P4). An
app without a fallback of its own used to get axum's empty-bodied 404 and
bodiless 405; they are now404 {"error":"Not found"}and405 {"error":"Method not allowed"}withcontent-type: application/json(or
theerror_projection::<E>()envelope).CatchPanicLayer::with_hook(hook)
is replaced byCatchPanicLayer::with(hook, projector)and
catch_panic_layer_withtakes the projector too. - Breaking:
SchemaGapgains theErrorBodyInlined { component }variant
(r2e-openapi, see Added). Exhaustivematches onSchemaGapneed a new
arm or a fallback.
Added
-
example-app: custom error envelope demo —
Problem(RFC 9457
application/problem+json,Validationremapped to 422) in
examples/example-app/src/error.rs,ProblemController(/problems) showing a
route envelope next to an infallible route,.error_projection::<AppError>()
installed inapp.rs, andtests/http/error_envelope.rspinning the bodies
and the per-route OpenAPI error responses. The book chapter Error Handling
gains a "Rejection, envelopes and projection" section, OpenAPI an "Error
responses" section, and the 0.4 → 0.5 migration guide is linked from the book
and the README. -
Rejection— one typed value for every framework failure (task #1072,
phase P0 ofplans/error-projection.md).r2e_core::error::Rejection { kind: RejectionKind, status, message, details, headers, source }is the hub every
fault converts into with plainFrom: the axumJson/Path/Query/Form
rejections,ParamError(by its newlocation),HttpError,GuardError,
MultipartError, garde reports,SecurityError(withWWW-Authenticate),
TenantError(TenantError::into_rejection(TenantStatuses)keeps the
configured statuses), a rawResponse(kindOpaque).
RejectionKind::default_status()is the one status table and
RejectionKind::from_statusits inverse.Rejection::project::<E>()renders
through an envelopeE: From<Rejection> + IntoHttpResponse + ErrorSchema;
HttpErroris the default envelope and its bodies are byte-equal to 0.4. -
ErrorSchema(r2e_core::error, prelude): the static side of an error
envelope —status_of(kind),body_schema(),body_schema_for(kind),
extra_statuses(),opaque_passthrough()— read by the runtime and, from
phase P2, by the OpenAPI builder. -
Typed guard errors:
r2e_security::RolesDenied,
r2e_rate_limit::RateLimited { retry_after }(emitsRetry-After, seconds
rounded up) andr2e_openfga::FgaDenied. Each isError, converts into
Rejection, and renders through it; the built-in guards use them and their
bodies are unchanged. -
#[derive(ApiError)]:#[error(rejection)]on one variant holding a
Rejection(alias:#[error(transparent)]over aRejectionfield). The
derive then also emitsFrom<Rejection>andErrorSchema. An enum with
exactly one#[error(transparent)]variant overHttpErrorinherits both
impls.status/messageon that variant and two such variants are compile
errors. -
One projection point per route (task #1072, phase P1). Every HTTP
route, SSE and WS endpoint is now one generated entry fn(State, Request) -> Responsethat owns the whole pipeline — pre-auth guards → request data
(identity +#[inject(request)]) → guards → head parameters → body
parameter (last) → garde validation → managed acquire → interceptors →
handler → managed finalize — and converts every failure into aRejection
projected once through the route's error envelope. The envelope is
inferred from the handler's return type:Result<T, E>with
E: From<Rejection> + IntoHttpResponse + ErrorSchemaprojects throughE
(no attribute to write); any other return type uses the app-level
projection. Guards run before the route's own parameters, so a denied or
unauthenticated request never reads its body. New tests:
r2e-core/tests/http/projection.rs. -
AppBuilder::error_projection::<E>()— the app-level error envelope
(the JAX-RS/QuarkusExceptionMapperequivalent), provided as the
ErrorProjectorbean (r2e_core::ErrorProjector,of::<E>()/
project(rejection)). Routes whose return type declares no envelope, SSE
and WS routes, and the framework's own responses (phase P4: the catch-panic
500, the router's 404 and 405, theJsonextractor's 413) render through
it; without the bean the default isHttpError, byte-equal to 0.4. -
Framework 404/405/panic through the application envelope (task #1072,
phase P4).build_innerreads theErrorProjectorbean once
(ErrorProjector::default()=HttpError) and hands it to the two
catch-panic slots (CatchPanicLayer::with(hook, projector), the 500 being
Rejection::internal("Internal server error")projected) and to the
router: a frameworkfallbackansweringRejection::not_found("Not found")
— installed only when nothing else claimed the fallback, so a controller
#[fallback], a mergedRouter::fallback(..)and ther2e-staticSPA
fallback keep winning — and amethod_not_allowed_fallbackanswering the new
RejectionKind::MethodNotAllowed(405;Allowkept).E::status_ofapplies
to all three. New:r2e_http::routing::has_custom_fallback(&Router)(reads
the bit axum only exposes throughRouter'sDebugoutput; pinned by
r2e-http/tests/routing.rs). Tests:r2e-core/tests/http/fallback.rs,
tests/http/panic.rs. -
OpenAPI error responses from the route's real failures (task #1072,
phase P2).RouteInfogainsrejection_kinds: Vec<RejectionKind>(inferred
by#[routes]: body extractor kinds,Path/Query/Form/#[derive(Params)]
locations, garde validation, identity (required or optional), roles/guards, rate-limit
guards, alwaysInternal) anderror_schema: Option<ErrorSchemaInfo>(the
envelope of aResult<T, E>return type).build_specdocuments one
response per distinctErrorSchema::status_of(kind)of the route's envelope
— the route's own, else the application'serror_projection::<E>()(read
from theErrorProjectorbean byOpenApiPlugin, or set with
OpenApiConfig::with_error_schema::<E>()), elseHttpError— with the
envelope's body components andextra_statuses; bodies are deduplicated
by schema and distinct bodies on one status become ananyOf; an error
body whose component name is taken by a different schema (DTO, registry,
nested$defs) is inlined and warned about (SchemaGap::ErrorBodyInlined,
build_spec_with_warnings). Newr2e_core::error::ErrorSchemaInfo(Copy
capture of anErrorSchemaimpl, also exposed asErrorProjector::schema())
andr2e_core::di::meta::RequestBodySchema(a custom last-parameter body
extractor documents its content type, schema and rejection kinds). -
RequestData<S>(r2e_core::web::extract): the R2E-owned trait the
generated__R2eRequestData_<C>extractor implements —
extract(&mut Parts, &S) -> Result<Self, Rejection>— replacing its
FromRequestPartsbridge impl. -
gRPC guards and identity (task #1072, phase P3).
#[grpc_routes]
methods and impl blocks take#[guard(..)],#[roles(..)]and
#[all_roles(..)]— the HTTPGuard<I>impls, built once at registration
throughDecoratorSpecwith their bean deps checked at
register_grpc_service— and#[inject(identity)]method parameters
(AuthenticatedUserorOption<AuthenticatedUser>, any position). The
guard sees aGuardContextbuilt byr2e_grpc::guard_context(request
metadata asheaders,extensions,peer_addr). Per call: identity →
controller guards → method guards → interceptors → method. -
r2e_grpc::GrpcIdentity: how an#[inject(identity)]gRPC parameter is
read —type Spec: DecoratorSpecresolves the extractor from the graph at
registration (so the bean is a compile-time dependency of the service),
extract/extract_optionalread the metadata per call and return a
Rejection.r2e-security(featuregrpc) implements it for
AuthenticatedUserwithJwtIdentitySpec(product: the
Arc<JwtClaimsValidator>bean):authorization: Bearer <jwt>, validated by
the same bean HTTP uses.r2e_grpc::bearer_tokenis the typed metadata read. -
r2e_grpc::rejection_to_status(+code_from_status): projects a
Rejectionontotonic::Statusby kind — Unauthenticated →
UNAUTHENTICATED, Forbidden →PERMISSION_DENIED, NotFound →NOT_FOUND,
Conflict →ABORTED, RateLimited/PayloadTooLarge →RESOURCE_EXHAUSTED,
Unavailable →UNAVAILABLE, Timeout →DEADLINE_EXCEEDED, Internal →
INTERNAL, request-shape kinds →INVALID_ARGUMENT, others by HTTP status;
the message becomes the status message and the rejection's headers
(Retry-After,WWW-Authenticate) become response metadata. A free
function becauseFrom<Rejection> for Statuswould be an orphan impl. -
McpError: From<Rejection>: MCP guard rejections map by kind
(Unauthenticated →Unauthorized, Forbidden →Forbidden, NotFound →
NotFound, request-shape kinds →InvalidParams, Internal/Unavailable/
Timeout →Internal, Conflict/RateLimited →Toolwith the details, others
by status) instead of re-reading a rendered response body. -
Compile-time checks on gRPC services: a
#[roles]method (or any guard
whose spec setsREQUIRES_IDENTITY) without an#[inject(identity)]
parameter, a struct-level#[inject(identity)], and a missing
Arc<JwtClaimsValidator>bean all fail to build. -
StopPhase::AfterDrainbackground services (task #1071). A
ServiceComponentcan declarefn stop_phase() -> StopPhaseand
fn stop_order() -> i32— with the derive,
#[service(stop = "after_drain", order = N)]. Such a service is cancelled
after the HTTP drain and the tracked-handle join, in a new shutdown step 5,
one service at a time in ascending order (each join bounded by
shutdown_grace_period), so a sink fed by request handlers (write-behind,
audit/outbox) sees the last requests' output instead of being torn down at
step 2 while the listener is still serving. Its token hangs off a separate
post-drain root with its own drop guard, so a droppedrun()future or a
droppedRunningAppstill cancels/aborts it.orderwithout
stop = "after_drain"and an unknownstopvalue are compile errors. -
on_shutdown_after_drain_asyncplugin hook (PluginBuildContextand
DeferredContext): an async cleanup hook awaited at step 5, after the
after-drain services, for resources that handlers and sinks still use
during the drain.
Changed
MultipartErrornow implementsstd::error::Error.r2e-executor: the pool drains after the HTTP drain, not before it
(task #1071). TheExecutorplugin's graceful drain moved from
on_shutdown_async(step 2) toon_shutdown_after_drain_async(step 5).
Previously a draining pool rejected everysubmitwith
RejectedError::Shutdownwhile in-flight handlers were still running, so a
handler — or a sink fed by one — could not hand work to the pool during the
drain. The shutdown sequence is now six steps; see
docs/features/22-serve-lifecycle.md.
Fixed
- gRPC: controller-level and method-level guards get separate
GuardContexts, as on HTTP and MCP: impl-block guards are built once per
controller and run withmethod_name: "*", method guards with the method
name. A controller-levelRateLimiton a gRPC service is now one
service-wide budget instead of being charged twice per call under the
method's key. - Rejection headers merge per name on every path.
Rejection::project
now adds the hub's headers to an opaque passthrough response too (a
Retry-Afteradded toRejection::from(response)used to be dropped), and
keeps every value of a repeated header (twoWWW-Authenticatechallenges).
A header name the rendered response already carries still wins. Result<impl Trait, E>routes keep their envelope. The return-type
probe can't name an opaque success type, so such routes fell back to the
app-level projection (runtime and OpenAPI); they now project throughE.r2e-executor: an aborted job no longer leaks the pool's counters
(task #1066).JobHandle::abortdrops the job future at its next await, so
the bookkeeping written afterfut.awaitnever ran: one aborted running job
pinneddrain_countabove zero forever and everyshutdown_gracefulafter
it sat out the wholeexecutor.shutdown-timeouton an idle pool; an aborted
queued job leaked itsqueuedslot and madetry_submitreject early. The
counters are now paired through RAII guards (QueuedGuard,RunningGuard)
whose drops land on a return, an unwind and a dropped future alike, and both
submission paths share one job body (run_job).