Skip to content

fix: send only the multipart Content-Type on post_multipart - #9

Closed
M3gA-Mind wants to merge 1 commit into
tinyhumansai:mainfrom
M3gA-Mind:fix/sdk-multipart-content-type
Closed

M3gA-Mind wants to merge 1 commit into
tinyhumansai:mainfrom
M3gA-Mind:fix/sdk-multipart-content-type

Conversation

@M3gA-Mind

Copy link
Copy Markdown
Collaborator

Summary

post_multipart sent two Content-Type header lines on every upload.

headers() sets Content-Type: application/json for the crate's JSON routes, and
post_multipart applied it and then called .multipart(form). reqwest's
RequestBuilder::header — which .multipart() uses to set the boundary type —
appends rather than replaces (reqwest-0.12.28/src/async_impl/request.rs:223), so
both values survived: the inherited JSON one first, the real multipart one second.

Node/Express keeps only the first per RFC 7230 §3.2.2, so the backend saw
application/json on a multipart body, handed it to express.json(), and 500'd on the
--<boundary> delimiter.

Fix

Drop the inherited Content-Type before .multipart(), so reqwest's value — the only one
that carries the boundary — is the sole one on the wire:

let mut headers = self.headers()?;
headers.remove(CONTENT_TYPE);

Removing from the shared headers() rather than building a parallel header map keeps
auth/accept/x-sdk-client on a single source of truth. HeaderMap::remove clears all
values for the key, so a host-supplied Content-Type from with_default_headers is
dropped too — correct here, since only reqwest can know the boundary.

Also documented the footgun on headers() itself, so the next body-typed method does not
reintroduce it.

Impact

Every SDK multipart upload was mislabelled:

  • POST /agent-integrations/file-storage/files
  • POST /agent-integrations/history-rewards/uploads
  • POST /openai/v1/audio/transcriptions

Backend Sentry BACKEND-NODEJS-49 (14 users), client Sentry TAURI-RUST-QBN (11 users).

Tests

tests/multipart.rs (new, wiremock per repo convention):

  • a multipart request carries exactly one Content-Type, and it is
    multipart/form-data; boundary=…
  • auth and static headers survive the removal
  • JSON routes still send a single application/json

The first test was run against the unfixed code before any change and failed with
precisely the production shape:

expected exactly one Content-Type, got
["application/json", "multipart/form-data; boundary=20ed0d33cb8237ec-…"]

— the same two-header ordering that produced --7a302eff in the backend Sentry event, so
this is a confirmed reproduction rather than an inferred one. (wiremock's hyper-based
server preserves duplicate header values in its HeaderMap, unlike Node, which is what
makes the bug observable in-process.)

Notes

Consumers need a re-pin. openhuman vendors this crate as a submodule, so
TAURI-RUST-QBN stays open on the client side until an SDK release lands and openhuman
bumps its pin.

Already-shipped clients are unblocked separately. tinyhumansai/backend#1182 adds a
server-side repair that recovers the real media type from req.rawHeaders. That is what
helps users running today's builds; this PR removes the cause.

Fixes #8

`post_multipart` applied `headers()` — which sets
`Content-Type: application/json` for the crate's JSON routes — and then
called `.multipart(form)`. reqwest's `RequestBuilder::header` appends
rather than replaces, so every upload went out with two `Content-Type`
lines: the inherited JSON one first, the real multipart one second.

Node/Express keeps only the first per RFC 7230 §3.2.2, so the backend saw
`application/json` on a multipart body, handed it to `express.json()`, and
500'd on the `--<boundary>` delimiter. Every SDK multipart upload was
mislabelled: `/agent-integrations/file-storage/files`,
`/agent-integrations/history-rewards/uploads` and
`/openai/v1/audio/transcriptions` (backend Sentry BACKEND-NODEJS-49,
client Sentry TAURI-RUST-QBN).

Drop the inherited `Content-Type` before `.multipart()` so reqwest's
value — the only one that carries the boundary — is the sole one on the
wire. Credential and static headers are untouched.

Tests assert a multipart request carries exactly one `Content-Type` and
that it is `multipart/form-data; boundary=…`, that auth and static headers
survive, and that JSON routes still send a single `application/json`. The
first fails against the previous behaviour with both values present.

Fixes tinyhumansai#8

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

M3gA-Mind has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 27 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: feead25a-dc92-4b86-a079-3acf398370a8

📥 Commits

Reviewing files that changed from the base of the PR and between 495be81 and 6613485.

📒 Files selected for processing (2)
  • src/lib.rs
  • tests/multipart.rs

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

@senamakel

Copy link
Copy Markdown
Member

Closing as superseded — the fix this PR describes already landed on main.

#11 (23a2f6e, merged 2026-08-04) applies the identical change: headers.remove(CONTENT_TYPE) before .multipart(form) in HttpClient::post_multipart, plus a tests/multipart.rs regression suite asserting exactly one Content-Type on the wire. main today carries both; cargo test is green.

That is why this branch now reports CONFLICTING. It also branched before #12, #13, #14 and #15, so merging it as-is would revert the Composio statusReason/isDisabled fields, the optional socket feature gate, and the spend-cap routes — the diff against current main drops UNEXPOSED_ROUTES from 49 back to 44, which would silently unblock five admin/webhook routes at the raw transport gate.

The one thing here that is not on main is the doc comments on post_multipart and headers() recording the append-not-replace footgun. Those are worth keeping — happy to carry them over in a follow-up with credit to you. Thanks for the writeup; the repro against the unfixed code was exactly right.

@senamakel senamakel closed this Aug 20, 2026
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.

post_multipart sends duplicate Content-Type (json + multipart) → backend parses multipart as JSON (500)

2 participants