Skip to content

refactor(middleware): split middleware.rs into a folder + relocate token_bucket (Finding 2) - #1151

Merged
slin1237 merged 1 commit into
mainfrom
refactor/middleware-folder-and-token-bucket
Apr 15, 2026
Merged

slin1237 merged 1 commit into
mainfrom
refactor/middleware-folder-and-token-bucket

Conversation

@slin1237

@slin1237 slin1237 commented Apr 15, 2026 •

Copy link
Copy Markdown
Member

Summary

Combines Finding 2 (relocate worker/token_bucket.rs out of the worker module) and the long-overdue break-up of middleware.rs into a single PR. Pure structural refactor — zero behavior change, and every external import path stays valid via re-exports in middleware/mod.rs.

Why one PR

middleware.rs was importing crate::worker::token_bucket::TokenBucket via pub use, so the cleanest moment to relocate token_bucket is when middleware itself becomes a folder. Doing the two changes in separate PRs would create a transient middleware.rs → middleware/mod.rs → middleware.rs flip that nobody needs.

Layout

Before

model_gateway/src/middleware.rs           (1110 lines, 8 concerns)
model_gateway/src/worker/token_bucket.rs

After

model_gateway/src/middleware/
├── mod.rs              (re-exports for backward compat)
├── auth.rs             (AuthConfig + auth_middleware)
├── concurrency.rs      (TokenGuardBody, QueuedRequest, QueueProcessor,
│                        ConcurrencyLimiter, concurrency_limit_middleware)
├── logging.rs          (RequestSpan, RequestLogger, ResponseLogger,
│                        create_logging_layer)
├── metrics.rs          (HttpMetricsLayer, HttpMetricsMiddleware,
│                        normalize_path_for_metrics, is_dynamic_id + tests)
├── request_id.rs       (RequestIdLayer, RequestIdMiddleware,
│                        generate_request_id, RequestId re-export)
├── storage_context.rs  (storage_context_middleware + helpers + tests)
├── token_bucket.rs     (relocated from worker/, git rename → history follows)
└── wasm.rs             (wasm_middleware)

What changed

  • model_gateway/src/middleware.rs: deleted (1110 lines distributed across the 8 files above, byte-for-byte identical except for per-file imports).
  • model_gateway/src/middleware/mod.rs: new — registers all submodules and re-exports the public surface (AuthConfig, auth_middleware, ConcurrencyLimiter, QueueProcessor, QueuedRequest, TokenGuardBody, concurrency_limit_middleware, create_logging_layer, RequestLogger, RequestSpan, ResponseLogger, HttpMetricsLayer, HttpMetricsMiddleware, RequestId, RequestIdLayer, RequestIdMiddleware, storage_context_middleware, TokenBucket, wasm_middleware).
  • model_gateway/src/worker/token_bucket.rs → model_gateway/src/middleware/token_bucket.rs: git rename, file content unchanged. Tracks as a 100% rename in the diff.
  • model_gateway/src/worker/mod.rs: drops pub mod token_bucket;.
  • Internal cross-module wiring inside the new folder:
    • concurrency.rs imports super::token_bucket::TokenBucket
    • logging.rs imports super::metrics::normalize_path_for_metrics and super::request_id::RequestId
    • wasm.rs imports super::request_id::{generate_request_id, RequestId}
    • generate_request_id is pub(super) so wasm.rs can use it without leaking it past the module boundary
    • normalize_path_for_metrics is pub(super) so logging.rs can use it
  • Test modules followed their code: build_storage_request_context_* tests live in storage_context.rs; the normalize_path_* and is_dynamic_id tests live in metrics.rs.

Public surface preserved

  • crate::middleware::TokenBucket still resolves (now via middleware/mod.rs:pub use token_bucket::TokenBucket), so the existing callers in app_context.rs:19 and service_discovery.rs:1240 are untouched.
  • Every crate::middleware::{AuthConfig, QueuedRequest, …} import in server.rs still resolves via the re-exports.
  • RequestId re-export from smg_auth is preserved (now in middleware/request_id.rs).
  • Net: no caller outside worker/mod.rs needs an import change.

What this unblocks

  • worker/ no longer hosts a primitive that has zero coupling to the Worker abstraction — Finding 2 closed.
  • middleware.rs is no longer a 1110-line bag of concerns; future middleware additions can live in their own files instead of growing the monolith.
  • The folder layout sets a precedent: one concern per file, each file imports only what it uses.

What this does NOT do

  • No new middleware trait. Tower's Layer/Service is already the ecosystem-standard abstraction; introducing a project-local trait would just be a wrapper around it.
  • No behavior change. No test assertion changes (tests moved alongside their code, but the assertions are identical).
  • No removal of public API. Every name that was public before is still public.

Test plan

  • cargo check -p smg — clean.
  • cargo clippy -p smg --all-targets -- -D warnings — clean.
  • cargo clippy -p smg-golang --all-targets -- -D warnings — clean.
  • cargo check -p smg-python — clean.
  • cargo test -p smg --lib — 546 passed; 0 failed; 4 ignored.
  • make fmt — clean.
  • rustup run nightly cargo fmt -- --check — EXIT=0 (matches CI; learned from refactor(worker): extract HashRing into its own module (Finding 5) #1129's format failure to always run nightly fmt before push).

Refs: Finding 2 in .claude/plans/2026-04-14-worker-module-followup-cleanup.md

Summary by CodeRabbit

  • Refactor
    • Reorganized and refactored the internal middleware architecture for improved code structure and maintainability.

…ken_bucket

Combines Finding 2 (relocate `worker/token_bucket.rs`) and a long-overdue
break-up of `middleware.rs` from `.claude/plans/2026-04-14-worker-module-followup-cleanup.md`
into a single PR. Pure structural refactor — zero behavior change, every
external import path stays valid via re-exports in `middleware/mod.rs`.

Why one PR
- `middleware.rs` was importing `crate::worker::token_bucket::TokenBucket`
  via `pub use`, so the cleanest moment to relocate token_bucket is when
  middleware itself becomes a folder. Doing the two changes in separate
  PRs would create a transient `middleware.rs` → `middleware/mod.rs` →
  `middleware.rs` flip that nobody needs.

Layout
Before:
  model_gateway/src/middleware.rs           (1110 lines, 8 concerns)
  model_gateway/src/worker/token_bucket.rs

After:
  model_gateway/src/middleware/mod.rs              (re-exports for back-compat)
  model_gateway/src/middleware/auth.rs             (AuthConfig + auth_middleware)
  model_gateway/src/middleware/concurrency.rs      (TokenGuardBody, QueuedRequest,
                                                    QueueProcessor, ConcurrencyLimiter,
                                                    concurrency_limit_middleware)
  model_gateway/src/middleware/logging.rs          (RequestSpan, RequestLogger,
                                                    ResponseLogger, create_logging_layer)
  model_gateway/src/middleware/metrics.rs          (HttpMetricsLayer,
                                                    HttpMetricsMiddleware,
                                                    normalize_path_for_metrics +
                                                    is_dynamic_id + tests)
  model_gateway/src/middleware/request_id.rs       (RequestIdLayer,
                                                    RequestIdMiddleware,
                                                    generate_request_id, RequestId
                                                    re-export from smg_auth)
  model_gateway/src/middleware/storage_context.rs  (storage_context_middleware +
                                                    helpers + tests)
  model_gateway/src/middleware/token_bucket.rs     (relocated from worker/, git
                                                    rename so history follows)
  model_gateway/src/middleware/wasm.rs             (wasm_middleware)

What changed
- `model_gateway/src/middleware.rs`: deleted (1110 lines distributed across
  the 8 files above, byte-for-byte identical except for per-file imports).
- `model_gateway/src/middleware/mod.rs`: new — registers all submodules and
  re-exports the public surface (`AuthConfig`, `auth_middleware`,
  `ConcurrencyLimiter`, `QueueProcessor`, `QueuedRequest`, `TokenGuardBody`,
  `concurrency_limit_middleware`, `create_logging_layer`, `RequestLogger`,
  `RequestSpan`, `ResponseLogger`, `HttpMetricsLayer`, `HttpMetricsMiddleware`,
  `RequestId`, `RequestIdLayer`, `RequestIdMiddleware`,
  `storage_context_middleware`, `TokenBucket`, `wasm_middleware`).
- `model_gateway/src/worker/token_bucket.rs` → `model_gateway/src/middleware/token_bucket.rs`:
  git rename, file content unchanged. Tracks as a rename in the diff.
- `model_gateway/src/worker/mod.rs`: drops `pub mod token_bucket;`.
- Internal cross-module wiring inside the new folder:
  * `concurrency.rs` imports `super::token_bucket::TokenBucket`.
  * `logging.rs` imports `super::metrics::normalize_path_for_metrics` and
    `super::request_id::RequestId`.
  * `wasm.rs` imports `super::request_id::{generate_request_id, RequestId}`.
  * `generate_request_id` is `pub(super)` so `wasm.rs` can use it without
    leaking it past the module boundary.
  * `normalize_path_for_metrics` is `pub(super)` so `logging.rs` can use it.
- Test modules followed their code: `build_storage_request_context_*`
  tests live in `storage_context.rs`; the `normalize_path_*` and
  `is_dynamic_id` tests live in `metrics.rs`.

Public surface preserved
- `crate::middleware::TokenBucket` still resolves (now via
  `middleware/mod.rs:pub use token_bucket::TokenBucket`), so the existing
  callers in `app_context.rs:19` and `service_discovery.rs:1240` are
  untouched.
- Every `crate::middleware::{AuthConfig, QueuedRequest, …}` import in
  `server.rs` still resolves via the re-exports.
- `RequestId` re-export from `smg_auth` is preserved (now in
  `middleware/request_id.rs`).
- Net: no caller outside `worker/mod.rs` needs an import change.

What this unblocks
- `worker/` no longer hosts a primitive that has zero coupling to the
  Worker abstraction — Finding 2 closed.
- `middleware.rs` is no longer a 1110-line bag of concerns; future
  middleware additions can live in their own files instead of growing
  the monolith.
- The folder layout sets a precedent for the next reviewer: one concern
  per file, each file imports only what it uses.

What this does NOT do
- No new middleware trait. Tower's `Layer`/`Service` is already the
  ecosystem-standard abstraction; introducing a project-local trait
  would just be a wrapper.
- No behavior change. No test changes (tests moved alongside their
  code, but their assertions are identical).
- No removal of public API. Every name that was public before is still
  public.

Verification
- cargo check -p smg — clean.
- cargo clippy -p smg --all-targets -- -D warnings — clean.
- cargo clippy -p smg-golang --all-targets -- -D warnings — clean.
- cargo check -p smg-python — clean.
- cargo test -p smg --lib — 546 passed; 0 failed; 4 ignored.
- make fmt — clean.
- rustup run nightly cargo fmt -- --check — EXIT=0 (matches CI).

Refs: Finding 2 in .claude/plans/2026-04-14-worker-module-followup-cleanup.md
Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
@slin1237
slin1237 requested a review from CatherineSue as a code owner April 15, 2026 12:22
@github-actions github-actions Bot added the model-gateway Model gateway crate changes label Apr 15, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the monolithic middleware into a modular structure under model_gateway/src/middleware/, improving maintainability. The review feedback identifies potential token leaks in the concurrency limiter when requests are cancelled while queued or during execution. Additionally, an optimization was suggested for the WASM middleware to calculate timestamps once before the module execution loop to improve efficiency and consistency.

Comment on lines +139 to +164
if self.token_bucket.try_acquire(1.0).is_ok() {
// Got token immediately
debug!("Queue: acquired token immediately for queued request");
let _ = queued.permit_tx.send(Ok(()));
} else {
// Need to wait for token
let token_bucket = self.token_bucket.clone();

// Spawn task only when we actually need to wait
#[expect(
clippy::disallowed_methods,
reason = "fire-and-forget permit acquisition: task is bounded by remaining_timeout and communicates via oneshot; dropping the JoinHandle detaches the task but it self-terminates"
)]
tokio::spawn(async move {
if token_bucket
.acquire_timeout(1.0, remaining_timeout)
.await
.is_ok()
{
debug!("Queue: acquired token after waiting");
let _ = queued.permit_tx.send(Ok(()));
} else {
warn!("Queue: request timed out waiting for token");
let _ = queued.permit_tx.send(Err(StatusCode::REQUEST_TIMEOUT));
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of the queue processor can leak tokens if a request is cancelled while waiting in the queue or immediately after a token is acquired. If permit_tx.send() fails (because the receiver was dropped due to request cancellation), the acquired token is never returned to the bucket. Additionally, the spawned task should monitor for request cancellation while waiting for a token to avoid unnecessary acquisition. Note that data passed to the spawned task must have a 'static lifetime, so we use owned types or clones to ensure the data outlives the task.

            // Try to acquire token for this request
            if self.token_bucket.try_acquire(1.0).is_ok() {
                // Got token immediately
                debug!("Queue: acquired token immediately for queued request");
                if queued.permit_tx.send(Ok(())).is_err() {
                    debug!("Queue: request cancelled, returning token");
                    self.token_bucket.return_tokens_sync(1.0);
                }
            } else {
                // Need to wait for token
                let token_bucket = self.token_bucket.clone();
                let mut permit_tx = queued.permit_tx;

                // Spawn task only when we actually need to wait
                #[expect(
                    clippy::disallowed_methods,
                    reason = "fire-and-forget permit acquisition: task is bounded by remaining_timeout and communicates via oneshot; dropping the JoinHandle detaches the task but it self-terminates"
                )]
                tokio::spawn(async move {
                    tokio::select! {
                        _ = permit_tx.closed() => {
                            debug!("Queue: request cancelled while waiting for token");
                        }
                        res = token_bucket.acquire_timeout(1.0, remaining_timeout) => {
                            match res {
                                Ok(()) => {
                                    debug!("Queue: acquired token after waiting");
                                    if permit_tx.send(Ok(())).is_err() {
                                        debug!("Queue: request cancelled after acquisition, returning token");
                                        token_bucket.return_tokens_sync(1.0);
                                    }
                                }
                                Err(_) => {
                                    warn!("Queue: request timed out waiting for token");
                                    let _ = permit_tx.send(Err(StatusCode::REQUEST_TIMEOUT));
                                }
                            }
                        }
                    }
                });
            }
References
  1. Data passed to spawned background tasks must have a 'static lifetime. Use owned types or reference-counted pointers like Arc instead of passing references to ensure the data outlives the task.

Comment on lines +238 to +248
if token_bucket.try_acquire(1.0).is_ok() {
debug!("Acquired token immediately");
Metrics::record_http_rate_limit(metrics_labels::RATE_LIMIT_ALLOWED);
let response = next.run(request).await;

// Wrap the response body with TokenGuardBody to return token when stream ends
// This ensures that for streaming responses, the token is only returned
// after the entire stream has been sent to the client.
let (parts, body) = response.into_parts();
let guarded_body = TokenGuardBody::new(body, token_bucket, 1.0);
Response::from_parts(parts, Body::new(guarded_body))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is a potential token leak in concurrency_limit_middleware. If the request is cancelled (e.g., the client disconnects) during the execution of next.run(request).await, the TokenGuardBody is never created, and the token acquired at line 238 is leaked. To fix this, you should use a RAII guard that returns the token on drop unless it is explicitly disarmed after the response is successfully wrapped in TokenGuardBody.

Comment on lines +105 to +118
for module in modules_on_request {
let wasm_headers = build_wasm_headers_from_axum_headers(&headers);
let wasm_request = WasmRequest {
method: method_str.clone(),
path: path_str.clone(),
query: query_str.clone(),
headers: wasm_headers,
body: modified_body.clone(),
request_id: request_id.clone(),
now_epoch_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_millis(0))
.as_millis() as u64,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calculating the current timestamp inside the loop for every WASM module is slightly inefficient and could lead to inconsistent timestamps across different modules for the same request. It is better to calculate the timestamp once before the loop.

Suggested change
for module in modules_on_request {
let wasm_headers = build_wasm_headers_from_axum_headers(&headers);
let wasm_request = WasmRequest {
method: method_str.clone(),
path: path_str.clone(),
query: query_str.clone(),
headers: wasm_headers,
body: modified_body.clone(),
request_id: request_id.clone(),
now_epoch_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_millis(0))
.as_millis() as u64,
};
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_millis(0))
.as_millis() as u64;
for module in modules_on_request {
let wasm_headers = build_wasm_headers_from_axum_headers(&headers);
let wasm_request = WasmRequest {
method: method_str.clone(),
path: path_str.clone(),
query: query_str.clone(),
headers: wasm_headers,
body: modified_body.clone(),
request_id: request_id.clone(),
now_epoch_ms: now_ms,
};

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean structural refactor — verified re-exports are complete, visibility changes are minimal (pub(super) only), no stale import paths remain, and tests moved with their code. LGTM.

@coderabbitai

coderabbitai Bot commented Apr 15, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The pull request refactors a monolithic 1110-line middleware module into eight specialized submodules—auth, concurrency, logging, metrics, request_id, storage_context, token_bucket, and wasm—each handling distinct HTTP middleware concerns. The original middleware.rs is deleted and replaced with a modular structure via middleware/mod.rs, with approximately 1200 lines of new middleware logic distributed across the submodules.

Changes

Cohort / File(s) Summary
Middleware Module Reorganization
model_gateway/src/middleware/mod.rs, model_gateway/src/worker/mod.rs
Created new middleware/mod.rs to organize and re-export all middleware submodules and types; removed token_bucket export from worker/mod.rs.
Authentication Middleware
model_gateway/src/middleware/auth.rs
Introduced Bearer-token authentication using SHA-256 hash precomputation and constant-time comparison via subtle crate; returns 401 when validation fails.
Concurrency Limiting
model_gateway/src/middleware/concurrency.rs
Implemented token-bucket rate limiting with optional request queuing, timeout handling, and TokenGuardBody wrapper for releasing tokens after response streaming completes.
HTTP Logging & Tracing
model_gateway/src/middleware/logging.rs
Built tracing integration via TraceLayer with W3C trace context extraction, request/response hooks, and structured logging for latency/status recording.
HTTP Metrics Collection
model_gateway/src/middleware/metrics.rs
Added middleware layer tracking in-flight requests and recording duration metrics; includes path normalization logic to reduce metric cardinality.
Request ID Generation
model_gateway/src/middleware/request_id.rs
Implemented OpenAI-style request ID generation from path segments with fallback to random alphanumeric; propagates via x-request-id response header.
Storage Context
model_gateway/src/middleware/storage_context.rs
Created middleware for extracting configured request headers and attaching them as StorageRequestContext for downstream handlers.
WASM Module Processing
model_gateway/src/middleware/wasm.rs
Implemented bidirectional WASM middleware at OnRequest and OnResponse attach points with body buffering, streaming detection, and support for Continue/Reject/Modify actions.
File Removal
model_gateway/src/middleware.rs (deleted)
Removed original monolithic middleware file containing all prior implementations.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • PR #472: Directly modifies the same middleware implementations—auth_middleware (SHA-256 hashing, constant-time comparison), wasm_middleware (preserve extensions, streaming detection, error responses), and logging/response types.
  • PR #406: Wires the newly exported auth_middleware and concurrency_limit_middleware into realtime REST routes; depends on changes in this PR.
  • PR #1079: Touches the same TokenBucket re-export and middleware.rs module; code-level dependency on reorganization structure.

Suggested labels

wasm, tests

Suggested reviewers

  • CatherineSue
  • key4ng
  • claude

Poem

🐰 Middleware scattered, now neatly arranged,
In folders and modules, the gateway's exchanged!
Auth tokens hash'd, queues taming the flow,
Logs and metrics bloom, watch the whole system glow—
A rabbit's refactor, from chaos to light! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.05% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main refactoring objective—splitting a monolithic middleware file into a folder structure and relocating token_bucket—which aligns with the substantial structural changes across multiple new middleware modules.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/middleware-folder-and-token-bucket

Warning

Review ran into problems

🔥 Problems

Timed out fetching pipeline failures after 30000ms


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@model_gateway/src/middleware/auth.rs`:
- Around line 39-51: The current strip_prefix("Bearer ") call is case-sensitive
and can miss valid tokens; update the token extraction around
request.headers().get(header::AUTHORIZATION) to perform a case-insensitive
"bearer " check (e.g., test h.len() > 7 and h[..7].eq_ignore_ascii_case("bearer
") then take &h[7..]) instead of strip_prefix, so the token variable, the
authorized computation (which uses Sha256::digest and expected_hash), and the
existing authorized logic remain unchanged but now accept any ASCII case
variation of the Bearer scheme.

In `@model_gateway/src/middleware/concurrency.rs`:
- Around line 76-84: The comment on poll_frame should use the repository
convention for non-unsafe assumptions: replace the "SAFETY:" marker with
"INVARIANT:" and keep the explanatory text that the inner body is never moved
and that Body is Unpin (type alias for UnsyncBoxBody which is Unpin); update the
comment above Pin::new(&mut this.inner).poll_frame(cx) to read "INVARIANT: We
never move the inner body, and Body is Unpin (it's a type alias for
UnsyncBoxBody which is Unpin)".

In `@model_gateway/src/middleware/logging.rs`:
- Around line 57-77: The RequestLogger::on_request currently attempts to read
RequestId from request.extensions but RequestIdLayer runs after TraceLayer, so
the ID will not be present here; either reorder middleware so RequestIdLayer
runs before TraceLayer if you need the ID recorded in the span at on_request
time, or (preferred per review) expand the existing comment near
RequestLogger/on_request to explicitly state that RequestId is intentionally
recorded later (because RequestIdLayer executes after TraceLayer) and document
where the request ID is actually attached (e.g., the layer or handler that
records it) so readers know why span.record("request_id", ...) may be empty.

In `@model_gateway/src/middleware/metrics.rs`:
- Around line 99-137: normalize_path_for_metrics can emit a trailing "{id}/" for
paths that end with a dynamic-id segment plus a slash; fix by tracking when you
replaced the current segment with "{id}" (e.g., a boolean like last_replaced set
inside the is_dynamic_id branch) and when handling the "if b == b'/'" branch
skip appending the trailing '/' if last_replaced is true and this slash is the
final byte of the path, otherwise append as before; ensure you clear
last_replaced when you append non-replacement segments so the flag only affects
the immediate trailing slash after an "{id}" replacement.

In `@model_gateway/src/middleware/wasm.rs`:
- Around line 230-240: Add a short clarifying comment in
model_gateway/src/middleware/wasm.rs inside the match for Action::Reject within
the OnResponse handling (the block that sets `status`, builds `final_response`,
assigns `*final_response.headers_mut() = headers`, and returns it) stating that
Reject during OnResponse intentionally preserves the current `headers` and
`body_bytes` (unlike OnRequest which returns an empty response) and explaining
the rationale (e.g., allow module to change status while keeping response
payload), so future readers know this semantic difference between OnRequest and
OnResponse.
- Around line 148-158: The unwrap_or_else on Request::builder() may swallow
build errors and drop the modified body; change the construction to match
Request::builder().method(method).uri(uri).body(Body::from(modified_body)) and
on Err(e) log the error (e.g. via tracing::error! or your logger) including e,
then fall back to creating a Request::new(Body::from(modified_body)) so the
modified_body is preserved; keep assigning *final_request.headers_mut() =
headers and *final_request.extensions_mut() = extensions before calling
next.run(final_request).await to ensure headers/extensions are preserved and the
error is visible in logs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 923e6cd0-cff9-4871-9752-d8b924050451

📥 Commits

Reviewing files that changed from the base of the PR and between e85af89 and 71fa089.

📒 Files selected for processing (11)
  • model_gateway/src/middleware.rs
  • model_gateway/src/middleware/auth.rs
  • model_gateway/src/middleware/concurrency.rs
  • model_gateway/src/middleware/logging.rs
  • model_gateway/src/middleware/metrics.rs
  • model_gateway/src/middleware/mod.rs
  • model_gateway/src/middleware/request_id.rs
  • model_gateway/src/middleware/storage_context.rs
  • model_gateway/src/middleware/token_bucket.rs
  • model_gateway/src/middleware/wasm.rs
  • model_gateway/src/worker/mod.rs
💤 Files with no reviewable changes (2)
  • model_gateway/src/worker/mod.rs
  • model_gateway/src/middleware.rs

Comment on lines +39 to +51
let token = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "));

let authorized = token.is_some_and(|t| {
Sha256::digest(t.as_bytes())
.as_slice()
.ct_eq(expected_hash)
.unwrap_u8()
== 1
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider case-insensitive "Bearer" prefix matching.

The strip_prefix("Bearer ") at line 43 is case-sensitive. Per RFC 6750, the "Bearer" scheme is case-insensitive. While most clients send "Bearer", some might send "bearer" or "BEARER".

♻️ Optional: case-insensitive Bearer prefix
         let token = request
             .headers()
             .get(header::AUTHORIZATION)
             .and_then(|h| h.to_str().ok())
-            .and_then(|h| h.strip_prefix("Bearer "));
+            .and_then(|h| {
+                h.strip_prefix("Bearer ")
+                    .or_else(|| h.strip_prefix("bearer "))
+                    .or_else(|| h.strip_prefix("BEARER "))
+            });

Or more robustly:

.and_then(|h| {
    if h.len() > 7 && h[..7].eq_ignore_ascii_case("bearer ") {
        Some(&h[7..])
    } else {
        None
    }
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model_gateway/src/middleware/auth.rs` around lines 39 - 51, The current
strip_prefix("Bearer ") call is case-sensitive and can miss valid tokens; update
the token extraction around request.headers().get(header::AUTHORIZATION) to
perform a case-insensitive "bearer " check (e.g., test h.len() > 7 and
h[..7].eq_ignore_ascii_case("bearer ") then take &h[7..]) instead of
strip_prefix, so the token variable, the authorized computation (which uses
Sha256::digest and expected_hash), and the existing authorized logic remain
unchanged but now accept any ASCII case variation of the Bearer scheme.

Comment on lines +76 to +84
fn poll_frame(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
// SAFETY: We never move the inner body, and Body is Unpin
// (it's a type alias for UnsyncBoxBody which is Unpin)
let this = self.get_mut();
Pin::new(&mut this.inner).poll_frame(cx)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Clarify the pin projection safety comment.

The comment at lines 80-81 states "SAFETY" but this isn't an unsafe block. Per repository conventions from learnings, SAFETY: should be reserved for unsafe blocks, while INVARIANT: documents assumptions in safe code. Since Body being Unpin is an invariant the code relies on, consider updating the comment.

♻️ Use INVARIANT marker
     fn poll_frame(
         self: Pin<&mut Self>,
         cx: &mut Context<'_>,
     ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
-        // SAFETY: We never move the inner body, and Body is Unpin
-        // (it's a type alias for UnsyncBoxBody which is Unpin)
+        // INVARIANT: Body is Unpin (type alias for UnsyncBoxBody), so we can
+        // safely project through Pin without needing unsafe.
         let this = self.get_mut();
         Pin::new(&mut this.inner).poll_frame(cx)
     }

Based on learnings: "In Rust code across the repository, use the marker INVARIANT: to document assumptions in safe code. Reserve SAFETY: for explaining why unsafe blocks are sound."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn poll_frame(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
// SAFETY: We never move the inner body, and Body is Unpin
// (it's a type alias for UnsyncBoxBody which is Unpin)
let this = self.get_mut();
Pin::new(&mut this.inner).poll_frame(cx)
}
fn poll_frame(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
// INVARIANT: Body is Unpin (type alias for UnsyncBoxBody), so we can
// safely project through Pin without needing unsafe.
let this = self.get_mut();
Pin::new(&mut this.inner).poll_frame(cx)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model_gateway/src/middleware/concurrency.rs` around lines 76 - 84, The
comment on poll_frame should use the repository convention for non-unsafe
assumptions: replace the "SAFETY:" marker with "INVARIANT:" and keep the
explanatory text that the inner body is never moved and that Body is Unpin (type
alias for UnsyncBoxBody which is Unpin); update the comment above Pin::new(&mut
this.inner).poll_frame(cx) to read "INVARIANT: We never move the inner body, and
Body is Unpin (it's a type alias for UnsyncBoxBody which is Unpin)".

Comment on lines +57 to +77
impl<B> OnRequest<B> for RequestLogger {
fn on_request(&mut self, request: &Request<B>, span: &Span) {
let _enter = span.enter();

// Try to get the request ID from extensions
// This will work if RequestIdLayer has already run
if let Some(request_id) = request.extensions().get::<RequestId>() {
span.record("request_id", request_id.0.as_str());
}

let method = method_to_static_str(request.method().as_str());
let path = normalize_path_for_metrics(request.uri().path());
Metrics::record_http_request(method, &path);

// Log the request start
info!(
target: "smg::request",
"started processing request"
);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Middleware ordering dependency: RequestId may not be available in on_request.

Lines 33-34 note that RequestIdLayer runs after TraceLayer, so RequestId won't be in extensions when on_request fires. The code handles this gracefully (line 63-65 is conditional), but this means request IDs won't appear in the span's request_id field. If this is intentional, the comment at line 120-121 could be expanded to clarify that request IDs are recorded elsewhere.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model_gateway/src/middleware/logging.rs` around lines 57 - 77, The
RequestLogger::on_request currently attempts to read RequestId from
request.extensions but RequestIdLayer runs after TraceLayer, so the ID will not
be present here; either reorder middleware so RequestIdLayer runs before
TraceLayer if you need the ID recorded in the span at on_request time, or
(preferred per review) expand the existing comment near RequestLogger/on_request
to explicitly state that RequestId is intentionally recorded later (because
RequestIdLayer executes after TraceLayer) and document where the request ID is
actually attached (e.g., the layer or handler that records it) so readers know
why span.record("request_id", ...) may be empty.

Comment on lines +99 to +137
pub(super) fn normalize_path_for_metrics(path: &str) -> String {
let bytes = path.as_bytes();
let mut segment_start = 0;
let mut segment_idx = 0;
let mut result: Option<String> = None;

for (pos, &b) in bytes.iter().enumerate() {
if b == b'/' || pos == bytes.len() - 1 {
// Determine segment end (include last char if not a slash)
let segment_end = if b == b'/' { pos } else { pos + 1 };
let segment = &path[segment_start..segment_end];

// Check segments after index 2 for dynamic IDs
if segment_idx > 2 && !segment.is_empty() && is_dynamic_id(segment) {
// Initialize result with everything before this segment
let result = result.get_or_insert_with(|| {
let mut s = String::with_capacity(path.len());
s.push_str(&path[..segment_start]);
s
});
result.push_str("{id}");
} else if let Some(ref mut r) = result {
// Already normalizing, append this segment as-is
r.push_str(segment);
}

// Add slash after segment (except at end)
if b == b'/' {
if let Some(ref mut r) = result {
r.push('/');
}
segment_start = pos + 1;
segment_idx += 1;
}
}
}

result.unwrap_or_else(|| path.to_owned())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Edge case: paths ending with slash may produce trailing {id}/.

The normalization logic handles most cases well, but when a path ends with a dynamic ID segment followed by a trailing slash (e.g., /v1/responses/resp_abc123def456/), the output might include {id}/ because the slash is appended after the segment replacement at line 128. This is likely acceptable for metrics purposes but worth noting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model_gateway/src/middleware/metrics.rs` around lines 99 - 137,
normalize_path_for_metrics can emit a trailing "{id}/" for paths that end with a
dynamic-id segment plus a slash; fix by tracking when you replaced the current
segment with "{id}" (e.g., a boolean like last_replaced set inside the
is_dynamic_id branch) and when handling the "if b == b'/'" branch skip appending
the trailing '/' if last_replaced is true and this slash is the final byte of
the path, otherwise append as before; ensure you clear last_replaced when you
append non-replacement segments so the flag only affects the immediate trailing
slash after an "{id}" replacement.

Comment on lines +148 to +158
// Reconstruct request with modifications, preserving original extensions
let mut final_request = Request::builder()
.method(method)
.uri(uri)
.body(Body::from(modified_body))
.unwrap_or_else(|_| Request::new(Body::empty()));
*final_request.headers_mut() = headers;
*final_request.extensions_mut() = extensions;

next.run(final_request).await
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Request::builder() error fallback loses request data.

At line 153, unwrap_or_else(|_| Request::new(Body::empty())) silently replaces a failed request build with an empty request. While Request::builder() errors are rare (invalid URI/method), this could silently drop the modified body. Consider logging the error.

♻️ Log builder errors
         let mut final_request = Request::builder()
             .method(method)
             .uri(uri)
             .body(Body::from(modified_body))
-            .unwrap_or_else(|_| Request::new(Body::empty()));
+            .unwrap_or_else(|e| {
+                error!("Failed to rebuild request after WASM processing: {}", e);
+                Request::new(Body::empty())
+            });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Reconstruct request with modifications, preserving original extensions
let mut final_request = Request::builder()
.method(method)
.uri(uri)
.body(Body::from(modified_body))
.unwrap_or_else(|_| Request::new(Body::empty()));
*final_request.headers_mut() = headers;
*final_request.extensions_mut() = extensions;
next.run(final_request).await
};
// Reconstruct request with modifications, preserving original extensions
let mut final_request = Request::builder()
.method(method)
.uri(uri)
.body(Body::from(modified_body))
.unwrap_or_else(|e| {
error!("Failed to rebuild request after WASM processing: {}", e);
Request::new(Body::empty())
});
*final_request.headers_mut() = headers;
*final_request.extensions_mut() = extensions;
next.run(final_request).await
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model_gateway/src/middleware/wasm.rs` around lines 148 - 158, The
unwrap_or_else on Request::builder() may swallow build errors and drop the
modified body; change the construction to match
Request::builder().method(method).uri(uri).body(Body::from(modified_body)) and
on Err(e) log the error (e.g. via tracing::error! or your logger) including e,
then fall back to creating a Request::new(Body::from(modified_body)) so the
modified_body is preserved; keep assigning *final_request.headers_mut() =
headers and *final_request.extensions_mut() = extensions before calling
next.run(final_request).await to ensure headers/extensions are preserved and the
error is visible in logs.

Comment on lines +230 to +240
match action {
Action::Continue => {}
Action::Reject(status_code) => {
status = StatusCode::from_u16(status_code).unwrap_or(StatusCode::BAD_REQUEST);
let mut final_response = Response::builder()
.status(status)
.body(Body::from(body_bytes))
.unwrap_or_else(|_| Response::new(Body::empty()));
*final_response.headers_mut() = headers;
return final_response;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Clarify Action::Reject semantics in OnResponse phase.

When a WASM module returns Action::Reject(status_code) during OnResponse (lines 232-239), the response is returned with the new status but retains the current headers and body_bytes. This might be intentional (allow module to reject while preserving body), but differs from OnRequest where Reject returns an empty response. Consider adding a comment to clarify the intended behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model_gateway/src/middleware/wasm.rs` around lines 230 - 240, Add a short
clarifying comment in model_gateway/src/middleware/wasm.rs inside the match for
Action::Reject within the OnResponse handling (the block that sets `status`,
builds `final_response`, assigns `*final_response.headers_mut() = headers`, and
returns it) stating that Reject during OnResponse intentionally preserves the
current `headers` and `body_bytes` (unlike OnRequest which returns an empty
response) and explaining the rationale (e.g., allow module to change status
while keeping response payload), so future readers know this semantic difference
between OnRequest and OnResponse.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

model-gateway Model gateway crate changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant