diff --git a/rdma/rcroutes/errors.go b/rdma/rcroutes/errors.go new file mode 100644 index 00000000..ee6221a7 --- /dev/null +++ b/rdma/rcroutes/errors.go @@ -0,0 +1,98 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an "AS +// IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language +// governing permissions and limitations under the License. + +package rcroutes + +import ( + "errors" + + "github.com/gofiber/fiber/v3" + + "github.com/versity/versitygw/s3api/utils" + "github.com/versity/versitygw/s3err" +) + +// writeRouteError renders err as the terminal S3-style XML response +// of an RC control route. The production S3 error handler converts +// ordinary Fiber errors into a generic 500 response, so the route +// must send its final status and body itself. Errors that already +// carry S3 semantics (authentication, authorization, object +// backend) keep their status and code; anything else is mapped to +// a protocol error without exposing internal detail. +func writeRouteError(ctx fiber.Ctx, err error) error { + requestID, hostID := utils.EnsureRequestIDs(ctx) + + var apiErr s3err.APIError + switch e := classifyRouteError(err).(type) { + case s3err.S3Error: + apiErr = e.BaseError() + case s3err.APIError: + apiErr = e + } + if isRouteNotImplemented(err) { + apiErr = s3err.APIError{ + Code: "NotImplemented", + Description: "RDMA is not supported on this platform", + HTTPStatusCode: fiber.StatusNotImplemented, + } + } + + ctx.Response().Header.SetContentType(fiber.MIMEApplicationXML) + return ctx.Status(apiErr.HTTPStatusCode). + Send(apiErr.XMLBody(requestID, hostID)) +} + +// classifyRouteError resolves err to a value implementing +// s3err.S3Error. S3-aware errors pass through unchanged; RC +// transport errors are mapped to the closest protocol error. +func classifyRouteError(err error) s3err.S3Error { + var s3Err s3err.S3Error + if errors.As(err, &s3Err) { + return s3Err + } + + code, status, description := routeErrorDetails(err) + return s3err.APIError{ + Code: code, + Description: description, + HTTPStatusCode: status, + } +} + +// routeErrorDetails maps a non-S3 route error to its protocol +// error code, HTTP status, and description. +func routeErrorDetails(err error) (code string, status int, description string) { + switch { + case isRouteBadRequest(err): + return "InvalidRdmaRequest", fiber.StatusBadRequest, + "Invalid RDMA control request" + case isRouteNotFound(err): + return "NoSuchRdmaSession", fiber.StatusNotFound, + "No such RDMA session" + case isRouteConflict(err): + return "RdmaSessionConflict", fiber.StatusConflict, + "RDMA session state conflict" + case isRouteLimit(err): + return "RdmaResourceLimit", fiber.StatusTooManyRequests, + "RDMA resource limit reached" + case isRouteBadGateway(err): + return "RdmaTransferFailed", fiber.StatusBadGateway, + "RDMA transfer failed" + case isRouteUnavailable(err): + return "RdmaServiceUnavailable", fiber.StatusServiceUnavailable, + "RDMA service is shutting down" + default: + return s3err.GetAPIError(s3err.ErrInternalError).Code, + fiber.StatusInternalServerError, "Internal Error" + } +} diff --git a/rdma/rcroutes/errors_linux.go b/rdma/rcroutes/errors_linux.go new file mode 100644 index 00000000..cfc0ef54 --- /dev/null +++ b/rdma/rcroutes/errors_linux.go @@ -0,0 +1,84 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an "AS +// IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language +// governing permissions and limitations under the License. + +//go:build linux && amd64 && cgo + +package rcroutes + +import ( + "errors" + + "github.com/versity/versitygw/rdma/rcserver" +) + +// This file keeps the Linux-only error classifiers next to the +// shared terminal serializer in errors.go. The marker types below +// carry only the intended protocol class; the HTTP status and XML +// body are decided in one place. + +// errRouteBadRequest marks a malformed control request (invalid +// header value, bad argument). +type errRouteBadRequest struct{} + +func (errRouteBadRequest) Error() string { return "invalid RDMA control request" } + +// errRouteUnavailable marks admission refusal during shutdown. +type errRouteUnavailable struct{} + +func (errRouteUnavailable) Error() string { + return "RDMA service is shutting down" +} + +// isRouteBadRequest reports whether err is a malformed-request +// class error from the Linux route handlers. +func isRouteBadRequest(err error) bool { + var bad errRouteBadRequest + return errors.As(err, &bad) +} + +// isRouteNotFound reports whether err identifies an unknown or +// cross-principal session; both map to the same response so the +// session id is not disclosed across principals. +func isRouteNotFound(err error) bool { + return errors.Is(err, rcserver.ErrNoSession) +} + +// isRouteConflict reports whether err is a stale, duplicate, or +// wrong-state session error. +func isRouteConflict(err error) bool { + return errors.Is(err, rcserver.ErrStale) || + errors.Is(err, rcserver.ErrState) || + errors.Is(err, rcserver.ErrDouble) +} + +// isRouteLimit reports whether err was caused by a configured +// resource limit. +func isRouteLimit(err error) bool { + return errors.Is(err, rcserver.ErrLimit) +} + +// isRouteBadGateway reports whether the RC wire transfer failed. +func isRouteBadGateway(err error) bool { + return errors.Is(err, rcserver.ErrWire) +} + +// isRouteUnavailable reports whether the RC service refused +// admission or is shutting down. +func isRouteUnavailable(err error) bool { + var unavail errRouteUnavailable + return errors.As(err, &unavail) +} + +// isRouteNotImplemented reports whether the platform stub answered +// the request; always false on Linux builds. +func isRouteNotImplemented(err error) bool { return false } diff --git a/rdma/rcroutes/errors_stub.go b/rdma/rcroutes/errors_stub.go new file mode 100644 index 00000000..4d8af5bb --- /dev/null +++ b/rdma/rcroutes/errors_stub.go @@ -0,0 +1,58 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an "AS +// IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language +// governing permissions and limitations under the License. + +//go:build !(linux && amd64 && cgo) + +package rcroutes + +import "errors" + +// errRouteNotImplemented marks the platform-stub answer of the +// control routes. +type errRouteNotImplemented struct{} + +func (errRouteNotImplemented) Error() string { + return "RDMA not supported on this platform" +} + +// isRouteNotImplemented reports whether the platform stub answered +// the request. +func isRouteNotImplemented(err error) bool { + var ni errRouteNotImplemented + return errors.As(err, &ni) +} + +// isRouteBadRequest reports whether err is a malformed-request +// class error. The stub never classifies request errors because it +// answers before validation. +func isRouteBadRequest(err error) bool { return false } + +// isRouteNotFound reports whether err identifies an unknown +// session. Never true on the stub. +func isRouteNotFound(err error) bool { return false } + +// isRouteConflict reports whether err is a session-state conflict. +// Never true on the stub. +func isRouteConflict(err error) bool { return false } + +// isRouteLimit reports whether err was caused by a resource +// limit. Never true on the stub. +func isRouteLimit(err error) bool { return false } + +// isRouteBadGateway reports whether the RC wire transfer failed. +// Never true on the stub. +func isRouteBadGateway(err error) bool { return false } + +// isRouteUnavailable reports whether the RC service refused +// admission. Never true on the stub. +func isRouteUnavailable(err error) bool { return false } diff --git a/rdma/rcroutes/routes_linux.go b/rdma/rcroutes/routes_linux.go index 11298cc2..af1d7a18 100644 --- a/rdma/rcroutes/routes_linux.go +++ b/rdma/rcroutes/routes_linux.go @@ -97,18 +97,27 @@ func principalID(acct auth.Account) rcserver.PrincipalID { } func errNotAdmitted() error { - return fiber.NewError(fiber.StatusServiceUnavailable, - "RDMA service is shutting down") + return errRouteUnavailable{} } func invalidHeader(name, value string) error { - return fiber.NewError(fiber.StatusBadRequest, - fmt.Sprintf("invalid %s header: %q", name, value)) + return fmt.Errorf("invalid %s header: %q: %w", + name, value, errRouteBadRequest{}) } // Prepare handles PREPARE: authorize the object access, create the // session, and (GET) stage the object into the session buffer. func (h *Handler) Prepare(ctx fiber.Ctx) error { + // Serialize any error at the route boundary: the + // production S3 error handler turns ordinary Fiber + // errors into a generic 500 response. + if err := h.prepareCore(ctx); err != nil { + return writeRouteError(ctx, err) + } + return nil +} + +func (h *Handler) prepareCore(ctx fiber.Ctx) error { if !h.svc.TryEnter() { return errNotAdmitted() } @@ -256,6 +265,16 @@ func (h *Handler) stageGet(ctx fiber.Ctx, sessionID, bucket, key string, // the session's target, then run the transfer. PUT picks up the // received data and hands it to the object backend. func (h *Handler) Ready(ctx fiber.Ctx) error { + // Serialize any error at the route boundary: the + // production S3 error handler turns ordinary Fiber + // errors into a generic 500 response. + if err := h.readyCore(ctx); err != nil { + return writeRouteError(ctx, err) + } + return nil +} + +func (h *Handler) readyCore(ctx fiber.Ctx) error { if !h.svc.TryEnter() { return errNotAdmitted() } @@ -295,7 +314,7 @@ func (h *Handler) Ready(ctx fiber.Ctx) error { if err != nil { // Owner mismatch and unknown session both answer 404 so // the session id is not disclosed cross-principal. - return fiber.NewError(fiber.StatusNotFound, "no such RDMA session") + return mapRcError(err) } // Re-run authorization for the session's stored target and @@ -303,7 +322,7 @@ func (h *Handler) Ready(ctx fiber.Ctx) error { // request. bucket, key, ok := splitTarget(info.Target) if !ok { - return fiber.NewError(fiber.StatusInternalServerError, "session target") + return errors.New("invalid session target") } if err := h.authorize(ctx, acct, isRoot, bucket, key, info.Op == 1); err != nil { // Permission revoked mid-session: cancel the session. @@ -335,7 +354,7 @@ func (h *Handler) Ready(ctx fiber.Ctx) error { // rolled the claim back (state Prepared, no completion ref), // so answer 409 without any finalizer. if resp.Outcome == rcserver.ReadyBusy { - return fiber.NewError(fiber.StatusConflict, "peer busy") + return fmt.Errorf("peer busy: %w", rcserver.ErrDouble) } // The claim succeeded: from here until the response commits, @@ -439,6 +458,16 @@ func (h *Handler) commitPut(ctx fiber.Ctx, sessionID, bucket, key string, // Cancel handles CANCEL: authenticated owner tears the session down. func (h *Handler) Cancel(ctx fiber.Ctx) error { + // Serialize any error at the route boundary: the + // production S3 error handler turns ordinary Fiber + // errors into a generic 500 response. + if err := h.cancelCore(ctx); err != nil { + return writeRouteError(ctx, err) + } + return nil +} + +func (h *Handler) cancelCore(ctx fiber.Ctx) error { if !h.svc.TryEnter() { return errNotAdmitted() } @@ -583,30 +612,15 @@ func formatHex(v uint64) string { } // mapRcError translates ABI statuses into HTTP-shaped failures. +// mapRcError wraps a session-server error so the shared terminal +// serializer in errors.go can classify it. Owner mismatch answers +// the same 404 as an unknown session so a session id is never +// disclosed across principals. func mapRcError(err error) error { - var status int - var msg string switch { - case errors.Is(err, rcserver.ErrNoSession): - status, msg = fiber.StatusNotFound, "no such RDMA session" - case errors.Is(err, rcserver.ErrStale): - status, msg = fiber.StatusConflict, "stale RDMA session handle" case errors.Is(err, rcserver.ErrSession): - status, msg = fiber.StatusForbidden, "RDMA session owner mismatch" - case errors.Is(err, rcserver.ErrState), errors.Is(err, rcserver.ErrDouble): - status, msg = fiber.StatusConflict, "wrong RDMA session state" - case errors.Is(err, rcserver.ErrLimit): - status, msg = fiber.StatusTooManyRequests, "RDMA resource limit" - case errors.Is(err, rcserver.ErrWire): - status, msg = fiber.StatusBadGateway, "RDMA transfer failed" - case errors.Is(err, rcserver.ErrShort): - status, msg = fiber.StatusBadRequest, "short RDMA transfer" - case errors.Is(err, rcserver.ErrTrunc): - status, msg = fiber.StatusBadRequest, "RDMA value too long" - case errors.Is(err, rcserver.ErrArg): - status, msg = fiber.StatusBadRequest, "invalid RDMA argument" - default: - status, msg = fiber.StatusInternalServerError, "RDMA internal error" + return fmt.Errorf("session owner mismatch: %w", + rcserver.ErrNoSession) } - return fiber.NewError(status, msg) + return err } diff --git a/rdma/rcroutes/routes_stub.go b/rdma/rcroutes/routes_stub.go index c692750b..ad8dc60b 100644 --- a/rdma/rcroutes/routes_stub.go +++ b/rdma/rcroutes/routes_stub.go @@ -34,16 +34,17 @@ func New(svc any, be backend.Backend, iam auth.IAMService, return &Handler{} } -func notImplemented() error { - return fiber.NewError(fiber.StatusNotImplemented, - "RDMA not supported on this platform") +// notImplemented answers 501 through the shared terminal error +// serializer so the response shape matches the Linux routes. +func notImplemented(ctx fiber.Ctx) error { + return writeRouteError(ctx, errRouteNotImplemented{}) } // Prepare is a stub handler that answers 501 Not Implemented. -func (h *Handler) Prepare(ctx fiber.Ctx) error { return notImplemented() } +func (h *Handler) Prepare(ctx fiber.Ctx) error { return notImplemented(ctx) } // Ready is a stub handler that answers 501 Not Implemented. -func (h *Handler) Ready(ctx fiber.Ctx) error { return notImplemented() } +func (h *Handler) Ready(ctx fiber.Ctx) error { return notImplemented(ctx) } // Cancel is a stub handler that answers 501 Not Implemented. -func (h *Handler) Cancel(ctx fiber.Ctx) error { return notImplemented() } +func (h *Handler) Cancel(ctx fiber.Ctx) error { return notImplemented(ctx) }