fix: answer unreadable request formats with 415, and count content types (DW-985)

Merged
CR
15h ago
+122-18

Stacked on #2082. Completes DW-985.

Why

RFC 9110 §15.5.16 reserves 415 for a request whose content format or coding the server doesn't support. #2082 returns it for unsupported content codings. Three other refusals in the same class still came back as a 400, which tells the client its request was malformed rather than sent in the wrong format:

  • POST /ai/v1/files without multipart/form-data, e.g. a raw JSONL body. This was a 400 "Invalid multipart request: invalid boundary", and it took an upload slot before being refused.
  • A batch file compressed with xz, bzip2, zip or lz4. #2082 names the format but returned a 400.
  • Strict-mode /responses with a body not declared as JSON. This was a 400 invalid_content_type.

All three already fail today, so changing the status breaks no working client.

What changes

  • dwctl: a new Error::UnsupportedMediaType variant, returned as a 415.
  • File uploads: a request without a multipart/form-data content type gets a 415 before it takes an upload slot. A multipart request with a broken form, such as a bad boundary, is still a 400. Files compressed in a format we don't decode also get a 415.
  • onwards, strict /responses: a missing JSON content type returns 415. The code stays invalid_content_type, so onwards_rejections_total series keep their label.
  • New metric: dwctl_inference_request_content_types_total{content_type} counts the content type declared on each POST to the inference endpoints, i.e. everything under /ai/v1/* except the file and batch routes, which already check theirs. Values are grouped into json, form, multipart, text, other and none. /chat/completions, /completions and /embeddings parse the body whatever it declares, and curl -d without -H declares a form body. Rejecting on content type there could break working clients, so this measures how many such requests there are before anyone decides.

Not in this PR

  • Rejecting non-JSON content types on the lenient endpoints: a follow-up, once the new metric shows how much traffic it would affect.
  • Non-UTF-8 charset parameters: deliberately left alone for the same reason.

Testing

  • test_upload_that_is_not_a_multipart_form_returns_415: a JSONL body, a form body and a missing content type each get a 415. A multipart body with a broken form still gets a 400.
  • test_upload_broken_or_unsupported_compression_names_the_problem: xz gets a 415, and truncated gzip still gets a 400.
  • responses_bodies_not_sent_as_json_are_counted_as_such in onwards: now asserts a 415 and the unchanged rejection code.
  • classifies_declared_content_types: covers the metric's labels.