Skip to content

HttpApiSchema.asMultipart returns 500 for multipart size-limit violations #6482

Description

@jpenilla

What version of Effect is running?

effect@4.0.0-beta.99

What steps can reproduce the bug?

Create a small project:

npm init -y
npm pkg set type=module
npm install effect@4.0.0-beta.99 @effect/platform-node@4.0.0-beta.99 tsx

Add src.ts:

import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
import { Effect, Layer, Schema } from "effect"
import { createServer } from "node:http"
import { HttpRouter, Multipart } from "effect/unstable/http"
import {
  HttpApi,
  HttpApiBuilder,
  HttpApiEndpoint,
  HttpApiGroup,
  HttpApiSchema,
} from "effect/unstable/httpapi"

const UploadPayload = Schema.Struct({
  file: Multipart.SingleFileSchema,
}).pipe(
  HttpApiSchema.asMultipart({
    maxParts: 1,
    maxFileSize: 1024,
    maxTotalSize: 4096,
  }),
)

class Uploads extends HttpApiGroup.make("uploads").add(
  HttpApiEndpoint.post("upload", "/upload", {
    payload: UploadPayload,
    success: Schema.String,
  }),
) {}

class Api extends HttpApi.make("api").add(Uploads) {}

const UploadsLive = HttpApiBuilder.group(
  Api,
  "uploads",
  Effect.fn(function* (handlers) {
    return handlers.handle("upload", ({ payload }) =>
      Effect.sync(() => {
        console.log("HANDLER INVOKED", payload.file.name)
        return "ok"
      }),
    )
  }),
)

const RoutesLive = HttpApiBuilder.layer(Api).pipe(
  Layer.provide(UploadsLive),
)

const ServerLive = HttpRouter.serve(RoutesLive).pipe(
  Layer.provide(
    NodeHttpServer.layer(createServer, {
      port: 32145,
    }),
  ),
)

Layer.launch(ServerLive).pipe(NodeRuntime.runMain)

Start the server:

npx tsx src.ts

In another terminal, create files below and above the configured limit:

head -c 10 /dev/zero > small.txt
head -c 2048 /dev/zero > large.txt

Upload the small file:

curl -i -F 'file=@small.txt;type=text/plain' \
  http://127.0.0.1:32145/upload

This behaves correctly:

HTTP/1.1 200 OK
content-type: application/json
content-length: 4

"ok"

The server prints:

HANDLER INVOKED small.txt

Upload the oversized file:

curl -i -F 'file=@large.txt;type=text/plain' \
  http://127.0.0.1:32145/upload

This returns:

HTTP/1.1 500 Internal Server Error

The response body is empty, the endpoint handler is not invoked, and the server logs:

MultipartError: FileTooLarge
...
'http.status': 500

What is the expected behavior?

Exceeding a configured file or request size limit is an expected client-caused request failure and should not become an unconditional 500 defect.

A sensible default would be:

  • FileTooLarge / BodyTooLarge413 Payload Too Large
  • TooManyParts / FieldTooLarge / Parse → an appropriate 4xx response
  • InternalError → defect / 500

Alternatively, HttpApi could expose multipart parsing failures through a request-decoding error transformation hook, allowing applications to choose their own response.

I am not expecting the endpoint handler to run after buffered multipart decoding fails. The issue is that the failure is converted into a defect before any supported HTTP API error transformation can handle it.

What do you see instead?

Buffered multipart decoding currently contains:

let eff = Effect.orDie(httpRequest.multipart)
if (existing.limits) {
  eff = Effect.provideContext(
    eff,
    Multipart.limitsServices(existing.limits),
  )
}
return Effect.flatMap(eff, decode)

Because httpRequest.multipart is wrapped in Effect.orDie, every MultipartError becomes a defect before payload schema decoding and before the endpoint handler.

This includes expected client-caused failures such as FileTooLarge, even though MultipartError distinguishes them from InternalError.

HttpApiMiddleware.layerSchemaErrorTransform does not provide an escape hatch because it handles HttpApiSchemaError, while the multipart failure has already been converted into a defect.

Additional information

Workarounds

HttpApiSchema.asMultipartStream preserves MultipartError in the stream error channel, so an endpoint can consume the stream and map the error manually.

However, switching to the streaming API means giving up the buffered API's schema-decoded SingleFileSchema and scoped PersistedFile input. The application must manually consume, validate, and potentially persist multipart parts.

An application-level byte counter is another partial workaround, but it duplicates the multipart parser limit and cannot handle errors raised before the endpoint receives the upload.

Additional information

This is related to, but distinct from, #6392.

#6392 fixed multipart limit violations being silently swallowed depending on request chunking. The fix is present in beta.99: this reproduction confirms that FileTooLarge is now raised reliably.

This issue concerns what happens afterward: buffered HttpApi converts that correctly raised request error into a defect and returns 500, with no supported path to a 413 or another application-defined response.

Metadata

Metadata

Assignees

Labels

4.0bugSomething isn't working

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions