Skip to content

feat: add request/response middleware pipeline - #2247

Open
Amr-Shams wants to merge 3 commits into
minio:masterfrom
Amr-Shams:feat/middleware-pipeline
Open

feat: add request/response middleware pipeline#2247
Amr-Shams wants to merge 3 commits into
minio:masterfrom
Amr-Shams:feat/middleware-pipeline

Conversation

@Amr-Shams

@Amr-Shams Amr-Shams commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a middleware pipeline to the minio-go client. You hook into
four phases: Initialize, Serialize, Finalize, Deserialize.

Pulled from AWS SDK's middleware.Stack. Same shape.

Architecture

The pipeline lives inside executeMethod() and newRequest().
No public API changes. Each middleware implements Middleware
(ID() string) plus one or more phase interfaces:

InitializeMiddleware → Initialize(ctx, execCtx)
SerializeMiddleware → Serialize(ctx, execCtx, req)
FinalizeMiddleware → Finalize(ctx, execCtx, req)
DeserializeMiddleware → Deserialize(ctx, execCtx, resp, err)

ExecutionContext carries Operation, BucketName,
ObjectName. Scope middleware logic to specific S3 operations
(verify integrity on GetObject, skip on ListBuckets).

Execution order

Initialize (once, before retry loop)
retry loop
Serialize once per attempt, before signing
Finalize once per attempt, before do()
do()
Deserialize once per attempt, after response

Error handling

  • Deserialize middleware errors stack with the transport error
    via errors.Join. Any error aborts the request, even on 2xx.
    Use this for response validation.
  • Initialize, Serialize, and Finalize errors short-circuit.

Why this instead of the old way

Before this, if you wanted custom headers, request logging, or
response verification, you had three options:

  1. Wrap the whole client. Hundreds of methods to duplicate.
  2. Swap http.RoundTripper. You lose bucket name, operation
    type, anything S3-specific.
  3. Fork the SDK.

Related

Prerequisite for #2246 (GetObject/FGetObject integrity verification).
The verifier will be a DeserializeMiddleware that wraps resp.Body
with a hash reader and checks against ETag or x-amz-checksum-*
headers.

Summary by CodeRabbit

  • New Features
    • Added configurable client-side middleware for the S3/HTTP request lifecycle, with hooks for initialization, request serialization/signing, finalization, and response deserialization.
    • Middleware receives an execution context (HTTP method, bucket/object info, and query parameters); initialization can adjust the request context.
    • Middleware can be registered via client options or AddMiddleware.
  • Bug Fixes
    • Deserialize middleware failures are now treated as request failures, even if the HTTP status indicates success.
  • Tests
    • Added an integration test covering hook order, single invocation per call, execution context capture, and header injection.

Add a 4-phase middleware pipeline (Initialize → Serialize →
Finalize → Deserialize) matching AWS SDK middleware.Stack.
Users inject custom logic into the S3 request lifecycle.

Signed-off-by: Amr-Shams <amr.shams2015.as@gmail.com>
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds a configurable S3 request middleware pipeline with lifecycle hooks for initialization, serialization, finalization, and deserialization. It also adds integration coverage and reformats existing calls and error expressions without changing their behavior.

Changes

Middleware Pipeline

Layer / File(s) Summary
Middleware interfaces and ExecutionContext
middleware.go
Defines operation metadata and middleware contracts for initialization, serialization, finalization, and deserialization.
Client and Options middleware wiring
api.go
Adds middleware storage and configuration, AddMiddleware, and client initialization from Options.Middlewares.
Operation execution hooks
api.go
Invokes middleware during initialization and retry attempts, runs serialization before signing, and joins deserialization errors with transport errors.
Middleware integration test
middleware_test.go
Verifies header injection, lifecycle invocation counts, HTTP method, and query metadata for GetBucketPolicy.

Formatting-only cleanup

Layer / File(s) Summary
Call and error formatting
200OKwithError_test.go, api-compose-object.go, api-get-options.go, core_test.go, functional_tests.go
Reformats existing calls and validation error expressions without changing arguments, conditions, assertions, or behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant executeMethod
  participant InitializeMiddleware
  participant newRequest
  participant SerializeMiddleware
  participant FinalizeMiddleware
  participant HTTPServer
  participant DeserializeMiddleware

  Caller->>executeMethod: call S3 operation
  executeMethod->>InitializeMiddleware: Initialize(ctx, execCtx)
  executeMethod->>newRequest: create request
  newRequest->>SerializeMiddleware: Serialize(ctx, execCtx, req)
  executeMethod->>FinalizeMiddleware: Finalize(ctx, execCtx, req)
  executeMethod->>HTTPServer: execute signed request
  HTTPServer-->>executeMethod: HTTP response
  executeMethod->>DeserializeMiddleware: Deserialize(ctx, execCtx, resp, err)
  executeMethod-->>Caller: response or joined error
Loading

Suggested labels: new feature

Poem

A rabbit hops through request and reply,
Middleware layers passing gently by,
Initialize, Serialize, Finalize too,
Deserialize checks what came through.
🐇✨ hop, sign, and send anew!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding a request/response middleware pipeline.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Comment thread api.go Outdated
Signed-off-by: Amr-Shams <amr.shams2015.as@gmail.com>

@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: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@api.go`:
- Line 1266: The issue is revive’s indent-error-flow warning caused by
unnecessary else blocks after return in the object-handling branches. In api.go,
update the conditional paths around the affected branches (the ones using
isObject in the same flow as the GET/other handlers) so each if block returns
directly and the else body is outdented into a following standalone block. Apply
the same pattern to all matching return-ending branches so the control flow is
flatter and golangci-lint passes.
- Around line 785-802: The DeserializeMiddleware error handling in api.go is
being lost on non-2xx responses because the code still proceeds into response
parsing and overwrites the joined error. Update the response handling around the
DeserializeMiddleware loop and the successStatus check so that if mwError is not
nil, the flow short-circuits before parsing the body into ErrorResponse,
preserving the joined error. Keep the fix localized to the existing
DeserializeMiddleware/error aggregation path and the success branch that
currently calls closeResponse and returns err.
- Around line 229-232: AddMiddleware currently mutates c.middlewares while
request handling reads that slice elsewhere, so middleware registration can race
with in-flight calls. Either make AddMiddleware setup-only and document that it
must be called before the Client is used, or add synchronization around all
middleware access in Client and the request path to prevent concurrent reads and
writes.

In `@middleware_test.go`:
- Around line 68-73: The Options struct literal passed to New in
middleware_test.go has gofumpt alignment issues, specifically the Middlewares
field spacing is inconsistent with the other fields. Reformat that Options
literal to match gofumpt’s standard struct-field alignment style, keeping the
existing New call and field values unchanged.
- Line 68: The test setup in New should not rely on a fixed-length slice of
server.URL to remove the scheme, because that is brittle if the URL format
changes. Replace the direct server.URL[7:] usage with a clearer prefix removal
approach such as trimming the "http://" prefix before passing the address into
New, and update the client construction in middleware_test.go accordingly.
- Around line 54-101: Add middleware error-path coverage to
TestMiddlewareIntegration by introducing subtests that force mockMiddleware to
fail in each phase. Verify that errors from Initialize, Serialize, and Finalize
immediately short-circuit the request and prevent later phases from running, and
that a Deserialize failure is returned even when the httptest server responds
with 200 OK. Use the existing mockMiddleware and client.GetBucketPolicy setup so
the assertions clearly exercise the middleware contract.
- Around line 78-80: The `err` assigned from `client.GetBucketPolicy(...)` in
`middleware_test.go` is never used, so either ignore it explicitly if the test
only cares that the request was made, or assert the expected error from
`GetBucketPolicy` instead of leaving an ineffectual assignment. Use the
`client.GetBucketPolicy` call site in the middleware test to either replace the
assignment with a discard or add a meaningful check so the test remains valid
and the linter warning is removed.

In `@middleware.go`:
- Around line 30-38: The `SerializeMiddleware` comment is incorrect about retry
behavior: `Serialize()` is invoked from `newRequest()` inside `executeMethod()`
on every retry attempt, not once per API call. Update the documentation on
`SerializeMiddleware` to remove the “safe phase for one-shot logging” claim and
make it clear that only `InitializeMiddleware` runs once before retries, using
the `SerializeMiddleware` and `InitializeMiddleware` symbols to locate the doc.
🪄 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 Plus

Run ID: e1ffc6c8-35f5-4675-be13-77ef5b05aa65

📥 Commits

Reviewing files that changed from the base of the PR and between ccfaaed and b2b343e.

📒 Files selected for processing (3)
  • api.go
  • middleware.go
  • middleware_test.go

Comment thread api.go
Comment thread api.go
Comment thread api.go Outdated
Comment thread middleware_test.go
Comment thread middleware_test.go Outdated
Comment thread middleware_test.go Outdated
Comment thread middleware_test.go Outdated
Comment thread middleware.go
@klauspost

Copy link
Copy Markdown
Contributor

Honestly, this seems overengineered for what it does. Usually I'd leave the overengineering to AWS.

Also the list-of-operations is a maintenance chore that I'd rather avoid.

@Amr-Shams

Copy link
Copy Markdown
Contributor Author

Honestly, this seems overengineered for what it does. Usually I'd leave the overengineering to AWS.

Also the list-of-operations is a maintenance chore that I'd rather avoid.

yea i see what you're pointing at. the 4-phase interface stack + type assertions is a lot of ceremony for what it does. but tbh the concept is useful, RoundTripper hacks lose context (bucket name, operation type, etc.).
what if i cut it down to just two interfaces: PreRequest and PostResponse? still gets the job done but the boilerplate drops by ~70%.
smt like:
type PreRequest interface {
PreRequest(ctx, ExecutionContext, *http.Request) error
}
type PostResponse interface {
PostResponse(ctx, ExecutionContext, *http.Response, error) error
}
slice of each in Options, call them in order. think that'd fly?

@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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
api.go (1)

940-952: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate ExecutionContext construction.

newRequest rebuilds its own ExecutionContext from metadata identical to the one already constructed in executeMethod (Lines 721-726). Consider threading the existing execCtx through as a parameter to newRequest instead of reconstructing it, keeping a single source of truth and avoiding drift if fields are added later.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api.go` around lines 940 - 952, `newRequest` is rebuilding an
`ExecutionContext` from `metadata`, duplicating the one already created in
`executeMethod`. Update `newRequest` to accept the existing `execCtx` as an
argument and use it directly in the `SerializeMiddleware` flow instead of
reconstructing it, so `ExecutionContext` stays a single source of truth and
won’t drift if fields change later.
♻️ Duplicate comments (5)
middleware_test.go (3)

83-83: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Ineffectual assignment to err persists.

err from client.GetBucketPolicy(...) is reassigned but never checked, confirmed by the ineffassign hint. Discard explicitly or assert the expected error.

🧹 Proposed fix
-	_, err = client.GetBucketPolicy(context.Background(), "mybucket")
+	_, _ = client.GetBucketPolicy(context.Background(), "mybucket")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@middleware_test.go` at line 83, The `middleware_test.go` test has an
ineffectual reassignment of `err` from `client.GetBucketPolicy`, so either stop
assigning it or immediately assert the expected result. Update the test around
`client.GetBucketPolicy` to explicitly check the returned error (or discard it
if it is intentionally unused) so the `err` value is not reassigned without
being validated.

Source: Linters/SAST tools


62-112: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Missing coverage for middleware error-handling contract.

Only the all-succeed happy path is tested. The PR objectives specify that Initialize/Serialize/Finalize errors short-circuit the request and Deserialize errors abort even on 2xx responses — none of this is exercised, same gap flagged previously.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@middleware_test.go` around lines 62 - 112, Add tests in
TestMiddlewareIntegration and related middleware test helpers to cover the
error-handling contract for mockMiddleware: verify that failures from
Initialize, Serialize, and Finalize stop the request before it reaches the
server, and that Deserialize errors are returned even when the HTTP response is
2xx. Use the existing mockMiddleware methods and client.GetBucketPolicy flow to
assert the short-circuit behavior and error propagation for each middleware
stage.

73-78: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Brittle URL prefix stripping remains.

server.URL[7:] assumes a fixed "http://" length; strings.TrimPrefix(server.URL, "http://") is safer against scheme changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@middleware_test.go` around lines 73 - 78, The New call in the middleware test
still relies on brittle fixed-length URL slicing. Replace the manual
server.URL[7:] trimming with a prefix-safe approach so the test works regardless
of the scheme, and update the setup around New and the mw middleware
initialization to use the resulting host string without assuming "http://".
api.go (2)

229-233: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Race between AddMiddleware and in-flight requests remains unresolved.

c.middlewares is appended here without synchronization, while executeMethod/newRequest iterate the same slice concurrently for in-flight requests. This was flagged previously and remains unaddressed in this revision — either document AddMiddleware as setup-only (call before the client is used) or add synchronization.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api.go` around lines 229 - 233, `Client.AddMiddleware` still mutates
`c.middlewares` without any protection while `executeMethod`/`newRequest` may
read it concurrently during active requests. Fix this by either making
`AddMiddleware` setup-only and documenting that it must be called before the
client is used, or by adding synchronization around middleware reads and writes
so the slice cannot race. Use the `AddMiddleware`, `executeMethod`, and
`newRequest` symbols to keep the behavior consistent across all middleware
access paths.

777-823: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

mwError is still dropped on the non-2xx / expect200OKWithError path.

mwError is joined into err at Line 786, but that err is unconditionally overwritten by io.ReadAll(...) at Line 800, then either silently discarded when apiErr == nil (Line 817-819 returns res, nil) or overwritten again by err = errResponse at Line 823. This is the exact gap raised in the earlier thread on this code (with a recorded learning that mwError must be folded into whichever error is ultimately returned, e.g. via errors.Join(errResponse, mwError) or returned directly when apiErr == nil), and it has not been fixed in this revision.

🐛 Suggested fix preserving mwError
-		apiErr := httpRespToErrorResponse(res, metadata.bucketName, metadata.objectName)
+		apiErr := httpRespToErrorResponse(res, metadata.bucketName, metadata.objectName)
+		var deserializeErr error
+		if mwError != nil {
+			deserializeErr = mwError
+		}
 
 		// Save the body back again.
 		bodySeeker.Seek(0, 0) // Seek back to starting point.
 		res.Body = io.NopCloser(bodySeeker)
 
 		if apiErr == nil {
+			if deserializeErr != nil {
+				closeResponse(res)
+				return nil, deserializeErr
+			}
 			return res, nil
 		}
 
 		// For errors verify if its retryable otherwise fail quickly.
 		errResponse := ToErrorResponse(apiErr)
-		err = errResponse
+		err = errResponse
+		if deserializeErr != nil {
+			err = errors.Join(errResponse, deserializeErr)
+		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api.go` around lines 777 - 823, The non-2xx and expect200OKWithError flow in
the response handling path still drops `mwError` after it is joined into `err`.
Update the logic in this return path so the middleware deserialize errors from
`DeserializeMiddleware.Deserialize` are preserved in whichever error is
ultimately returned: keep them attached when `httpRespToErrorResponse` produces
`apiErr`, when `ToErrorResponse` sets `errResponse`, and when `apiErr == nil`
but middleware errors exist. Use the existing `mwError`, `err`, `apiErr`, and
`errResponse` handling in this block to ensure `errors.Join` is applied before
returning.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@middleware.go`:
- Around line 10-15: ExecutionContext.QueryValues is currently sharing the same
url.Values map as requestMetadata.queryValues, so any middleware that calls
Serialize, Finalize, or Deserialize and mutates it will affect the original
request state. Fix this in the ExecutionContext construction path in api.go by
cloning the query map before assigning it, or otherwise make QueryValues
explicitly read-only; use the ExecutionContext and requestMetadata.queryValues
symbols to locate the shared-reference handoff.
- Around line 9-15: The ExecutionContext struct in middleware.go has gofumpt
alignment issues because some fields have inline comments and others do not.
Reformat the struct definition so the field declarations in ExecutionContext are
consistently aligned and any comment spacing matches gofumpt expectations,
keeping the identifiers ExecutionContext, Method, BucketName, ObjectName, and
QueryValues intact.

---

Outside diff comments:
In `@api.go`:
- Around line 940-952: `newRequest` is rebuilding an `ExecutionContext` from
`metadata`, duplicating the one already created in `executeMethod`. Update
`newRequest` to accept the existing `execCtx` as an argument and use it directly
in the `SerializeMiddleware` flow instead of reconstructing it, so
`ExecutionContext` stays a single source of truth and won’t drift if fields
change later.

---

Duplicate comments:
In `@api.go`:
- Around line 229-233: `Client.AddMiddleware` still mutates `c.middlewares`
without any protection while `executeMethod`/`newRequest` may read it
concurrently during active requests. Fix this by either making `AddMiddleware`
setup-only and documenting that it must be called before the client is used, or
by adding synchronization around middleware reads and writes so the slice cannot
race. Use the `AddMiddleware`, `executeMethod`, and `newRequest` symbols to keep
the behavior consistent across all middleware access paths.
- Around line 777-823: The non-2xx and expect200OKWithError flow in the response
handling path still drops `mwError` after it is joined into `err`. Update the
logic in this return path so the middleware deserialize errors from
`DeserializeMiddleware.Deserialize` are preserved in whichever error is
ultimately returned: keep them attached when `httpRespToErrorResponse` produces
`apiErr`, when `ToErrorResponse` sets `errResponse`, and when `apiErr == nil`
but middleware errors exist. Use the existing `mwError`, `err`, `apiErr`, and
`errResponse` handling in this block to ensure `errors.Join` is applied before
returning.

In `@middleware_test.go`:
- Line 83: The `middleware_test.go` test has an ineffectual reassignment of
`err` from `client.GetBucketPolicy`, so either stop assigning it or immediately
assert the expected result. Update the test around `client.GetBucketPolicy` to
explicitly check the returned error (or discard it if it is intentionally
unused) so the `err` value is not reassigned without being validated.
- Around line 62-112: Add tests in TestMiddlewareIntegration and related
middleware test helpers to cover the error-handling contract for mockMiddleware:
verify that failures from Initialize, Serialize, and Finalize stop the request
before it reaches the server, and that Deserialize errors are returned even when
the HTTP response is 2xx. Use the existing mockMiddleware methods and
client.GetBucketPolicy flow to assert the short-circuit behavior and error
propagation for each middleware stage.
- Around line 73-78: The New call in the middleware test still relies on brittle
fixed-length URL slicing. Replace the manual server.URL[7:] trimming with a
prefix-safe approach so the test works regardless of the scheme, and update the
setup around New and the mw middleware initialization to use the resulting host
string without assuming "http://".
🪄 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 Plus

Run ID: e7054872-e879-4121-8d2a-4ed628e7cf5d

📥 Commits

Reviewing files that changed from the base of the PR and between 71928fa and 4e2c194.

📒 Files selected for processing (3)
  • api.go
  • middleware.go
  • middleware_test.go

Comment thread middleware.go
Comment thread middleware.go
Comment thread middleware.go
@harshavardhana

Copy link
Copy Markdown
Member

@coderabbitai help

@minio minio deleted a comment from coderabbitai Bot Jul 7, 2026
@Amr-Shams
Amr-Shams force-pushed the feat/middleware-pipeline branch from 4e2c194 to 3babbb5 Compare July 11, 2026 11:19
@Amr-Shams
Amr-Shams requested a review from harshavardhana July 11, 2026 11:19
@Amr-Shams
Amr-Shams force-pushed the feat/middleware-pipeline branch from 3babbb5 to 398b260 Compare July 11, 2026 11:21

@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.

♻️ Duplicate comments (3)
middleware_test.go (3)

90-90: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Brittle URL prefix stripping still present.

server.URL[7:] assumes a fixed "http://" prefix length. Prefer strings.TrimPrefix(server.URL, "http://") for clarity and safety.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@middleware_test.go` at line 90, Update the New call in the middleware test to
replace the fixed server.URL[7:] slicing with strings.TrimPrefix(server.URL,
"http://"), adding the required strings import if needed.

100-100: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Ineffectual assignment to err still present.

err from client.GetBucketPolicy(...) is never checked. Static analysis confirms ineffassign at line 100. Since the test only exercises the middleware lifecycle, discard explicitly with _, _.

🧹 Proposed fix
-	_, err = client.GetBucketPolicy(context.Background(), "mybucket")
+	_, _ = client.GetBucketPolicy(context.Background(), "mybucket")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@middleware_test.go` at line 100, Update the GetBucketPolicy call in the
middleware lifecycle test to explicitly discard both return values with _, _,
removing the ineffectual assignment to err while preserving the existing test
flow.

Source: Linters/SAST tools


102-129: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Missing error-path coverage still unaddressed.

The test only exercises the happy path. The PR objectives specify that Initialize/Serialize/Finalize errors short-circuit the request and Deserialize errors are joined with transport errors via errors.Join (even on 2xx). None of this behavior is verified.

Consider adding subtests where a middleware returns an error at each phase, asserting short-circuiting for the first three and error surfacing for Deserialize even when the server responds 200 OK.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@middleware_test.go` around lines 102 - 129, Expand the middleware test around
the existing initialized, serialized, finalized, and deserialized assertions
with subtests that inject an error from each phase. Verify Initialize,
Serialize, and Finalize errors immediately short-circuit the request and prevent
later phases, and verify Deserialize errors are returned through errors.Join
with the transport error, including when the server responds 200 OK.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@middleware_test.go`:
- Line 90: Update the New call in the middleware test to replace the fixed
server.URL[7:] slicing with strings.TrimPrefix(server.URL, "http://"), adding
the required strings import if needed.
- Line 100: Update the GetBucketPolicy call in the middleware lifecycle test to
explicitly discard both return values with _, _, removing the ineffectual
assignment to err while preserving the existing test flow.
- Around line 102-129: Expand the middleware test around the existing
initialized, serialized, finalized, and deserialized assertions with subtests
that inject an error from each phase. Verify Initialize, Serialize, and Finalize
errors immediately short-circuit the request and prevent later phases, and
verify Deserialize errors are returned through errors.Join with the transport
error, including when the server responds 200 OK.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 0dc14c13-5f1b-4825-a1f1-a891771038c7

📥 Commits

Reviewing files that changed from the base of the PR and between 4e2c194 and 3babbb5.

📒 Files selected for processing (3)
  • api.go
  • middleware.go
  • middleware_test.go

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
api.go (2)

354-354: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Copy the caller-owned middleware slice.

The client retains opts.Middlewares’ backing array. Replacing its elements after New silently changes the active chain and can race with requests.

Proposed fix
-	clnt.middlewares = opts.Middlewares
+	clnt.middlewares = append([]Middleware(nil), opts.Middlewares...)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api.go` at line 354, Update the middleware assignment in New so the client
stores an independent copy of opts.Middlewares rather than retaining the
caller-owned slice backing array. Preserve the middleware order and existing
behavior while preventing later caller mutations from changing clnt.middlewares.

769-787: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Invoke DeserializeMiddleware for transport failures.

Lines 770-775 return or retry before the hooks run, so Deserialize always receives a nil transport error. This violates the new contract to expose and join transport errors. Run the hooks immediately after c.do, before deciding whether to retry or return, and cover the nil-response transport-error path in tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api.go` around lines 769 - 787, The request loop around c.do and the
DeserializeMiddleware invocation must run deserialization hooks immediately
after every transport attempt, including when c.do returns an error or nil
response. Pass the actual transport error to Deserialize, join transport and
middleware errors, then apply retry or return decisions using the combined error
as appropriate; add coverage for the nil-response transport-error path.
middleware.go (1)

39-43: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Do not claim that Initialize can alter metadata.

ExecutionContext is passed by value, so changes to Method, BucketName, or ObjectName are discarded. Describe the metadata as inspectable unless the API intentionally propagates an updated context.

Proposed documentation fix
 // InitializeMiddleware runs BEFORE the HTTP request is built.
-// It can mutate the context or alter metadata.
+// It can derive a new context or inspect operation metadata.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@middleware.go` around lines 39 - 43, Update the documentation for
InitializeMiddleware to state that it can inspect metadata, but cannot alter
Method, BucketName, or ObjectName because ExecutionContext is passed by value.
Remove the claim that metadata changes are propagated, unless the API is changed
to intentionally return or otherwise propagate an updated context.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@api.go`:
- Line 354: Update the middleware assignment in New so the client stores an
independent copy of opts.Middlewares rather than retaining the caller-owned
slice backing array. Preserve the middleware order and existing behavior while
preventing later caller mutations from changing clnt.middlewares.
- Around line 769-787: The request loop around c.do and the
DeserializeMiddleware invocation must run deserialization hooks immediately
after every transport attempt, including when c.do returns an error or nil
response. Pass the actual transport error to Deserialize, join transport and
middleware errors, then apply retry or return decisions using the combined error
as appropriate; add coverage for the nil-response transport-error path.

In `@middleware.go`:
- Around line 39-43: Update the documentation for InitializeMiddleware to state
that it can inspect metadata, but cannot alter Method, BucketName, or ObjectName
because ExecutionContext is passed by value. Remove the claim that metadata
changes are propagated, unless the API is changed to intentionally return or
otherwise propagate an updated context.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: caa26599-6f2c-4e10-947c-383d04dbfb32

📥 Commits

Reviewing files that changed from the base of the PR and between 3babbb5 and 398b260.

📒 Files selected for processing (8)
  • 200OKwithError_test.go
  • api-compose-object.go
  • api-get-options.go
  • api.go
  • core_test.go
  • functional_tests.go
  • middleware.go
  • middleware_test.go

@Amr-Shams
Amr-Shams force-pushed the feat/middleware-pipeline branch from 398b260 to 4e29fa4 Compare July 11, 2026 11:36
Signed-off-by: Amr-Shams <amr.shams2015.as@gmail.com>
@Amr-Shams
Amr-Shams force-pushed the feat/middleware-pipeline branch from 4e29fa4 to 8745b8c Compare July 11, 2026 11:59
@Amr-Shams

Copy link
Copy Markdown
Contributor Author

@harshavardhana PTAL

Comment thread api.go
clnt.maxRetries = opts.MaxRetries
}

clnt.middlewares = opts.Middlewares

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.

The client keeps the caller's slice header rather than a copy, so if the caller reuses or edits its own Middlewares slice after New the edit lands in this client's active chain — and it lands while in-flight requests may already be ranging over it at 728, 760, 779 and 947.

Suggested change
clnt.middlewares = opts.Middlewares
clnt.middlewares = append([]Middleware(nil), opts.Middlewares...)

Comment thread api.go
Comment on lines +229 to +232
// AddMiddleware adds a middleware to the chain.
func (c *Client) AddMiddleware(m Middleware) {
c.middlewares = append(c.middlewares, m)
}

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.

AddMiddleware appends to the same slice that executeMethod and newRequest range over (728, 760, 779, 947), with no synchronisation on either side, so calling it once the client is live is a data race. Documenting it as setup-only is the cheap option; a mutex would mean taking a lock on all four read paths. Consider which you'd rather have.

Suggested change
// AddMiddleware adds a middleware to the chain.
func (c *Client) AddMiddleware(m Middleware) {
c.middlewares = append(c.middlewares, m)
}
// AddMiddleware adds a middleware to the chain.
//
// It is not safe for concurrent use: call it during client setup, before the
// client issues any request.
func (c *Client) AddMiddleware(m Middleware) {
c.middlewares = append(c.middlewares, m)
}

Comment thread middleware.go
@@ -0,0 +1,77 @@
/*
* MinIO Go Library for Amazon S3 Compatible Cloud Storage
* Copyright 2015-2017 MinIO, Inc.

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.

This file is new in this PR, so a 2015-2017 range doesn't describe it. Other files added to the repo recently use the current year.

Suggested change
* Copyright 2015-2017 MinIO, Inc.
* Copyright 2026 MinIO, Inc.

Comment thread middleware.go
"net/url"
)

// ExecutionContext provides context metadata about the S3 operation currently running.

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.

The struct is copied per call, which makes it look inert, but QueryValues is a map — it points at the same values makeTargetURL reads, and the request metadata is reused across retries. A middleware calling Set or Del on it is editing shared request state, which is surprising enough to be worth a line here.

Suggested change
// ExecutionContext provides context metadata about the S3 operation currently running.
// ExecutionContext provides context metadata about the S3 operation currently running.
//
// It is passed by value, but QueryValues is the same url.Values the request
// URL is built from, not a copy: treat it as read-only.

Comment thread middleware.go
Comment on lines +39 to +40
// InitializeMiddleware runs BEFORE the HTTP request is built.
// It can mutate the context or alter metadata.

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.

execCtx arrives by value, so assigning to Method, BucketName or ObjectName inside Initialize is discarded — only the returned context survives, which makes "alter metadata" misleading. Separately: a middleware that returns (nil, nil) here puts a nil context straight into newRetryTimer, which calls Done() on it and panics the request. Stating the non-nil requirement is cheaper than a runtime check.

Suggested change
// InitializeMiddleware runs BEFORE the HTTP request is built.
// It can mutate the context or alter metadata.
// InitializeMiddleware runs BEFORE the HTTP request is built.
// It can derive a new context. execCtx is a copy, so assigning to its fields
// has no effect outside the middleware. Implementations must return a non-nil
// context; returning ctx unchanged is valid.

Comment thread middleware.go
Comment on lines +58 to +61
// FinalizeMiddleware runs AFTER the request is signed and ready to go out.
// Ideal for logging request sizes, outbound traffic, or tracing.
//
// NOTE: This fires on every retry attempt. Only Initialize runs once.

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.

Signing happens inside newRequest (around 956-1001) and Finalize runs after it returns, at 760. Since the phase hands out a mutable *http.Request, it is easy to set a header here and get a signature mismatch back from the server with nothing pointing at the cause.

Suggested change
// FinalizeMiddleware runs AFTER the request is signed and ready to go out.
// Ideal for logging request sizes, outbound traffic, or tracing.
//
// NOTE: This fires on every retry attempt. Only Initialize runs once.
// FinalizeMiddleware runs AFTER the request is signed and ready to go out.
// Ideal for logging request sizes, outbound traffic, or tracing.
//
// NOTE: This fires on every retry attempt. Only Initialize runs once.
// The request is already signed, so mutating headers here invalidates the
// signature; use Serialize for changes that need to be signed.

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.

Correction to my own suggestion above — the "use Serialize for changes that need to be signed" half is incomplete, and I would rather flag that than let it bite you.

Serialize runs at api.go:949, but several Header.Set calls come after it in the same function and overwrite unconditionally: x-amz-s3session-token (963), setUserAgent (1005), the metadata.customHeader loop (1009), Content-Md5 (1031), and X-Amz-Content-Sha256 (1037 and 1077). A Serialize middleware that sets any of those header names is silently discarded before signing. Serialize also sees req.Body == nil and ContentLength == 0, since both are assigned at 1017-1023, after the hook runs.

So the accurate version is narrower than what I wrote: Serialize is the right phase for headers the SDK does not set itself, and neither phase is a place to touch the SDK-managed ones or to inspect the body. Worth folding into the doc if you take the suggestion.

Comment thread middleware.go
Comment on lines +70 to +73
// NOTE: This fires on every retry attempt. Only Initialize runs once.
// Errors from all Deserialize middleware stack via errors.Join with the transport
// error. If any middleware returns a non-nil error, the request aborts even on
// 2xx responses.

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.

Two corrections and an addition. Transport failures are handled at api.go:770-776, before this loop, so Deserialize never sees one — a middleware registered against a refused dial records zero calls, and the transport error joined at 788 is always nil. The resp.Body note is new: the body is still unread here, so a middleware that reads it consumes it for the caller. I also dropped "the request aborts even on 2xx responses" rather than reword it, because which paths abort is exactly the open question in my other comment — worth restating once you've settled that.

Suggested change
// NOTE: This fires on every retry attempt. Only Initialize runs once.
// Errors from all Deserialize middleware stack via errors.Join with the transport
// error. If any middleware returns a non-nil error, the request aborts even on
// 2xx responses.
// NOTE: This fires on every attempt that produced a response. Only Initialize
// runs once; a transport failure short-circuits before this phase, so
// Deserialize never observes one. resp.Body has not been read yet, so
// consuming it here consumes it for the caller as well. Errors from all
// Deserialize middleware are joined via errors.Join.

Comment thread middleware_test.go
@@ -0,0 +1,133 @@
/*
* MinIO Go Library for Amazon S3 Compatible Cloud Storage
* Copyright 2015-2017 MinIO, Inc.

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.

Same as middleware.go: the file is new in this PR, so the 2015-2017 range doesn't describe it.

Suggested change
* Copyright 2015-2017 MinIO, Inc.
* Copyright 2026 MinIO, Inc.

Comment thread middleware_test.go
if len(mw.deserialized) != 1 {
t.Fatalf("Expected 1 deserialized call, got %d", len(mw.deserialized))
}
}

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.

The error contract the description sets out — the first three phases stopping the request before it goes out, and a Deserialize error aborting an otherwise successful response — isn't asserted anywhere, so a regression in any of it would stay green. This table covers all four phases, passes against the branch as it stands, and needs no import changes. It fails if the Finalize hook is removed, so it pins the behaviour rather than just exercising it. It deliberately has no non-2xx row, because that case does not pass today (see my comment on executeMethod) — worth adding once that is settled. Consider and test this.

Suggested change
}
}
type phaseError struct{ phase string }
func (e phaseError) Error() string { return "middleware " + e.phase + " failed" }
type phaseErrMiddleware struct {
mu sync.Mutex
failOn string
phases []string
}
func (m *phaseErrMiddleware) ID() string { return "phase-err-middleware" }
func (m *phaseErrMiddleware) record(phase string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.phases = append(m.phases, phase)
if m.failOn == phase {
return phaseError{phase: phase}
}
return nil
}
func (m *phaseErrMiddleware) Initialize(ctx context.Context, _ ExecutionContext) (context.Context, error) {
return ctx, m.record("initialize")
}
func (m *phaseErrMiddleware) Serialize(_ context.Context, _ ExecutionContext, _ *http.Request) error {
return m.record("serialize")
}
func (m *phaseErrMiddleware) Finalize(_ context.Context, _ ExecutionContext, _ *http.Request) error {
return m.record("finalize")
}
func (m *phaseErrMiddleware) Deserialize(_ context.Context, _ ExecutionContext, _ *http.Response) error {
return m.record("deserialize")
}
func TestMiddlewareErrorPropagation(t *testing.T) {
for _, tc := range []struct {
failOn string
wantPhases string
wantRequests int
}{
{failOn: "initialize", wantPhases: "initialize", wantRequests: 0},
{failOn: "serialize", wantPhases: "initialize,serialize", wantRequests: 0},
{failOn: "finalize", wantPhases: "initialize,serialize,finalize", wantRequests: 0},
{failOn: "deserialize", wantPhases: "initialize,serialize,finalize,deserialize", wantRequests: 1},
} {
t.Run(tc.failOn, func(t *testing.T) {
var requestsMu sync.Mutex
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requestsMu.Lock()
requests++
requestsMu.Unlock()
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
mw := &phaseErrMiddleware{failOn: tc.failOn}
client, err := New(strings.TrimPrefix(server.URL, "http://"), &Options{
Creds: credentials.NewStaticV4("mockkey", "mocksecret", ""),
Secure: false,
Region: "us-east-1",
Middlewares: []Middleware{mw},
})
if err != nil {
t.Fatalf("Failed to create minio client: %v", err)
}
_, err = client.GetBucketPolicy(context.Background(), "mybucket")
if err == nil || !strings.Contains(err.Error(), phaseError{phase: tc.failOn}.Error()) {
t.Fatalf("Expected the %s error to reach the caller, got %v", tc.failOn, err)
}
mw.mu.Lock()
defer mw.mu.Unlock()
if got := strings.Join(mw.phases, ","); got != tc.wantPhases {
t.Errorf("Expected phases %q, got %q", tc.wantPhases, got)
}
requestsMu.Lock()
defer requestsMu.Unlock()
if requests != tc.wantRequests {
t.Errorf("Expected %d server request(s), got %d", tc.wantRequests, requests)
}
})
}
}

Comment thread api.go
Comment on lines +787 to 796
if mwError != nil {
err = errors.Join(err, mwError)
}
success := successStatus.Contains(res.StatusCode)
if success && !metadata.expect200OKWithError {
// We do not expect 2xx to return an error return.
if mwError != nil {
closeResponse(res)
return nil, err
}
return res, nil

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.

A Deserialize error only reaches the caller on a plain 2xx. Everywhere else it is discarded: the value joined here is overwritten by io.ReadAll at 801 and then by err = errResponse at 824, and the apiErr == nil branch at 818 returns res, nil. Two measurements against this branch —

  • 404 with a middleware returning a sentinel: errors.Is(err, sentinel) is false; the caller sees only NoSuchBucket.
  • RemoveObjects (which sets expect200OKWithError, api-remove.go:638) returning 200: the hook runs once and RemoveObjects reports no error at all, so the failure is invisible on a 2xx too.

Worth knowing before picking a direction: the obvious repair — joining the middleware error into errResponse — has a trap. ToErrorResponse (api-error-response.go:79) is a bare type switch on ErrorResponse with a default returning an empty struct, so it does not unwrap, and errors.Join wraps even a single error. Any join on that path makes ToErrorResponse(err).Code come back "" for every caller that inspects it. That is already observable on the 2xx path today: with a failing Deserialize middleware and a 200, the returned error is an *errors.joinError and the code reads empty. Note this also makes the join at 788 redundant in its own right — err is provably nil here, since c.do's error paths at 770-776 either retry or return.

So the choice looks like: surface the middleware error only where there is no S3 error to report, or teach ToErrorResponse to try errors.As first and then join freely. Both seem like calls for you to make rather than something to patch inline.

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.

One case I left out of the list above, and it happens to be the one that most constrains the fix, so it is worth adding.

A retryable non-2xx discards the middleware error on every attempt, not just once. Measured on this branch with a 503/SlowDown handler and MaxRetries: 3: 3 server hits, 3 Deserialize calls, and errors.Is(err, sentinel) false — the caller sees only slow down.

That rules out the shape I would otherwise have suggested, hoisting the abort above the success check, because it would turn a retryable throttle into an immediate failure. Whatever direction you pick needs to keep the retry path intact and still not lose the error once retries are exhausted.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants