Skip to content

Make batch API parseable with web-standard FormData #769

Open

Description

@sentry-junior

The batch endpoint (POST /v1/objects:batch/{usecase}/{scopes}/) uses multipart/form-data, but all per-operation data lives in custom per-part headers:

  • Requests: the server reads x-sn-batch-operation-kind, x-sn-batch-operation-key, and insert metadata (content-type, x-sn-expiration, x-sn-origin, x-snme-*, …) from each part's headers (objectstore-server/src/extractors/batch.rs). Content-Disposition is ignored.
  • Responses: every part is name=part. Index, status, key, kind, and object metadata are in x-sn-batch-operation-* and metadata headers (objectstore-server/src/endpoints/batch.rs, objectstore-server/src/multipart.rs). Parts stream in completion order, so the index header is the only way to correlate a result with its operation.

The web FormData API (new FormData() + fetch for requests, Response.formData() for responses, also used by Node via undici) only exposes each part's name, filename, content type, and value. JS clients in Node, browsers, or Workers have to hand-roll a byte-level multipart encoder and parser, as getsentry/sentry-api-schema#133 does for the Sentry CLI's snapshot upload.

Tested on Node 22.23 (undici) against the current response shape:

  • Response.formData() throws Failed to parse body as FormData because the part name is unquoted (form-data; name=part).
  • With the name quoted, it parses, but every entry is named part, all per-part headers are dropped, and parts without a filename come back as strings (even with a Content-Type), which corrupts binary GET payloads.

Raised in review of getsentry/sentry-api-schema#133.

Proposal

  1. Quick, backward-compatible fix: quote the part name (form-data; name="part") so standards-based parsers no longer reject the response. This doesn't make the metadata readable, but it removes a spec deviation.
  2. Opt-in FormData-native format that carries metadata in form fields instead of part headers:
    • Request: a first part name="operations" (application/json) with an ordered list, e.g. [{"kind":"insert","key":"a/b","headers":{"content-type":"image/png","x-sn-expiration":"ttl:30d"}}, {"kind":"head","key":"c"}]. The headers map keeps existing names so the server can reuse Metadata::from_insert_headers. Insert bodies follow as parts named by operation index (name="0", with a filename and Blob), so a client just does form.append("0", blob, "0"). Because the manifest comes first, the server can still stream body parts.
    • Response: for each completed operation, a JSON part name="<index>" such as {"status":201,"kind":"insert","key":"a/b","headers":{…}} (error body included on failure). For a GET with a payload, a following part name="<index>.body" with filename and Content-Type set, so parsers return a File and binary stays intact. Completion-order streaming is preserved.
    • Negotiation: the server detects the request format from a leading operations part. The client opts into the new response format explicitly (e.g. ?format=form or an Accept profile parameter). Without it, the server keeps today's header format, so the Rust client and the in-progress Python batch client (feat(py-client): Implement "many" api for batch requests #546) are unaffected.

Alternatives

  • Status quo: keep the header protocol and maintain a hand-rolled parser in each JS client (~200 lines that must track the server).
  • Metadata in name/filename only (e.g. name="insert:3", filename=<key>): can't carry expiration or custom metadata, and filename escaping differs between implementations.
  • JSON body with base64 payloads: ~33% size overhead and no streaming.
  • Emit both formats at once: breaks existing clients, which expect one part per operation with an index header.

Open questions

  • Should the new format replace the per-part headers or sit alongside them?
  • Response.formData() buffers the whole response; should responses in the new format have a lower size cap?

via @BYK.

--

View Session [Sentry]

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions