Published on

OpenAI Responses API 415 Unsupported Media Type 해결

Authors

서버가 415 Unsupported Media Type를 반환한다는 건, 요청 본문(body)의 미디어 타입을 서버가 이해하지 못하거나(Content-Type 불일치), 아예 본문 파싱 단계에서 실패했다는 의미입니다. OpenAI Responses API에서 이 에러는 대부분 “JSON으로 보내야 하는데 JSON이 아니게 전송됨”, “멀티파트를 잘못 구성함”, “프록시/게이트웨이가 Content-Type을 바꿔치기함” 같은 전송 계층의 실수로 발생합니다.

이 글에서는 415를 재현 → 원인 분류 → 언어별 수정 코드 → 운영 환경에서의 방어 로직 순서로 정리합니다.

415가 뜨는 전형적인 상황 5가지

1) Content-Type: application/json이 빠졌거나 잘못됨

Responses API의 기본 요청은 JSON입니다. 그런데 다음 케이스가 흔합니다.

  • Content-Type을 아예 안 넣음
  • text/plain, application/x-www-form-urlencoded로 들어감
  • multipart/form-data를 수동으로 넣고 boundary를 누락함

특히 curl에서 -d만 쓰면 기본이 application/x-www-form-urlencoded로 잡히는 환경(또는 래퍼 스크립트)에서 415가 잘 터집니다.

2) JSON을 보내는 척하지만 실제로는 “문자열”을 전송

예: Python requests.post(..., data=json.dumps(payload))를 쓰면서 헤더를 빼먹거나, Node에서 body: payload(객체)인데 JSON.stringify를 안 하는 경우.

서버 입장에서는 Content-Type은 JSON인데 body가 JSON 파서로 해석 불가라서 400/415로 떨어질 수 있습니다(게이트웨이/프록시 구성에 따라 415로 매핑되기도 함).

3) 파일/이미지를 JSON 본문에 그대로 넣음(잘못된 업로드 방식)

Responses API에서 파일/이미지 입력은 보통 파일 업로드 엔드포인트를 따로 쓰거나, 지정된 포맷(예: URL, file_id, 혹은 지원되는 입력 구조)을 사용해야 합니다.

바이너리 데이터를 JSON에 그대로 박아 넣거나, multipart/form-data로 보내면서 필드 구성이 잘못되면 415가 발생합니다.

용량이 커서 실패하는 경우는 413으로 떨어지는 편이므로, 415면 우선 “형식”을 의심하세요. 큰 업로드 자체의 전략은 아래 글도 같이 참고하면 좋습니다.

4) 프록시/게이트웨이가 헤더를 변조하거나 제거

Nginx, API Gateway, Cloudflare, 사내 프록시가 다음을 수행하면 415가 생깁니다.

  • Content-Type을 강제로 덮어씀
  • Transfer-Encoding: chunked 처리 과정에서 body를 재인코딩
  • Expect: 100-continue 처리 버그

이 경우 클라이언트 코드는 정상인데도 서버가 415를 주므로, “실제 wire-level 요청”을 캡처해야 합니다.

5) 스트리밍/비동기 구현에서 body가 깨짐

스트리밍 응답을 받는 코드가 아니라, 요청을 보내는 쪽에서 chunk를 잘못 쓰거나(특히 저수준 http 라이브러리) Content-Length가 틀어지면 서버가 body를 파싱하지 못해 415/400을 반환할 수 있습니다.

스트리밍 자체의 타임아웃/끊김 이슈는 별개로 빈번하니, 운영에서는 함께 점검하는 편이 좋습니다.

가장 먼저 확인할 “정답 요청 형태”

Responses API의 핵심은 JSON POST입니다. 아래는 최소 형태의 예시입니다(필드명은 사용 중인 모델/기능에 따라 달라질 수 있으나, 핵심은 “JSON + 올바른 Content-Type”입니다).

curl https://api.openai.com/v1/responses \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-4.1-mini",
    "input": "Say hello in Korean"
  }'

여기서 415가 난다면, 높은 확률로 (1) 헤더가 실제로 전달되지 않았거나 (2) 프록시가 바꿔치기했거나 (3) 바디가 JSON이 아닌 형태로 전송되고 있습니다.

언어별 415 재현 코드와 수정 코드

Python (requests) — data= 대신 json=을 쓰는 게 안전

잘못된 예(415/400 유발 가능)

import requests, json

payload = {"model": "gpt-4.1-mini", "input": "hello"}

# Content-Type을 빼먹거나, data=로 보내면 환경에 따라 폼 인코딩/문자열 처리로 꼬일 수 있음
r = requests.post(
    "https://api.openai.com/v1/responses",
    headers={"Authorization": f"Bearer {OPENAI_API_KEY}"},
    data=json.dumps(payload),
)
print(r.status_code, r.text)

올바른 예

import requests

payload = {"model": "gpt-4.1-mini", "input": "hello"}

r = requests.post(
    "https://api.openai.com/v1/responses",
    headers={
        "Authorization": f"Bearer {OPENAI_API_KEY}",
        "Content-Type": "application/json",
    },
    json=payload,  # requests가 JSON 직렬화 + 헤더를 일관되게 처리
    timeout=30,
)
print(r.status_code)
print(r.json())

json=을 쓰면 직렬화/헤더 설정 실수를 줄일 수 있습니다. 다만 사내 프록시가 헤더를 건드리는 환경이라면, 아래 “와이어 레벨 확인”까지 진행해야 합니다.

Python (httpx) — content=json= 혼동 금지

import httpx

payload = {"model": "gpt-4.1-mini", "input": "hello"}

with httpx.Client(timeout=30) as client:
    r = client.post(
        "https://api.openai.com/v1/responses",
        headers={"Authorization": f"Bearer {OPENAI_API_KEY}"},
        json=payload,
    )
    r.raise_for_status()
    print(r.json())
  • content=는 바이트/문자열 raw body를 직접 넣을 때 사용합니다.
  • json=은 올바른 JSON 직렬화 + Content-Type: application/json을 보장합니다.

네트워크 오류/타임아웃 재시도 설계는 415와 별개지만, 운영에서는 함께 넣는 경우가 많습니다.

Node.js (fetch) — JSON.stringify 누락이 가장 흔한 실수

잘못된 예

await fetch("https://api.openai.com/v1/responses", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: { model: "gpt-4.1-mini", input: "hello" }, // 객체 그대로 들어가면 깨짐
});

올바른 예

await fetch("https://api.openai.com/v1/responses", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: "gpt-4.1-mini",
    input: "hello",
  }),
});

curl — -F(multipart)와 -d(json)를 섞지 말 것

  • JSON 요청: -d + Content-Type: application/json
  • 멀티파트 요청: -F를 쓰되 Content-Type을 수동으로 박지 말고 curl이 boundary를 만들게 두는 편이 안전

잘못된 예(멀티파트를 수동으로 선언)

curl https://api.openai.com/v1/responses \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: multipart/form-data" \
  -F "model=gpt-4.1-mini" \
  -F "input=hello"

위처럼 multipart/form-data를 수동으로 넣으면 boundary가 없어서 서버가 해석 못 할 수 있습니다.

수정 예

curl https://api.openai.com/v1/responses \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -F "model=gpt-4.1-mini" \
  -F "input=hello"

다만 Responses API는 기본적으로 JSON을 권장/가정하는 경우가 많으니, 파일 업로드가 필요하지 않다면 멀티파트 자체를 피하고 JSON으로 고정하는 것이 415 예방에 가장 좋습니다.

415를 10분 안에 끝내는 디버깅 체크리스트

###[1] 서버에 실제로 전달된 Content-Type을 확인 클라이언트 코드에서 헤더를 설정했더라도, 중간 프록시가 바꿀 수 있습니다.

  • 로컬: mitmproxy, tcpdump, wireshark
  • 서버 앞단: Nginx access log에 $http_content_type 기록

Nginx 예:

log_format with_ct '$remote_addr - $request $status ct=$http_content_type len=$content_length';
access_log /var/log/nginx/access.log with_ct;

###[2] body가 진짜 JSON인지(첫 바이트부터) 확인 서버가 기대하는 JSON은 보통 { 또는 [로 시작합니다.

  • bodymodel=gpt...&input=... 형태면 폼 인코딩입니다.
  • body"{...}"처럼 JSON 문자열이 한 번 더 감싸진 형태면 이중 인코딩입니다.

###[3] charset는 대체로 문제 아님, boundary는 문제

  • application/json; charset=utf-8는 보통 OK
  • multipart/form-data는 boundary가 없으면 거의 즉시 실패

###[4] SDK/라이브러리 버전과 래퍼 레이어 점검 사내 공통 HTTP 클라이언트(인터셉터)가 다음을 하는지 확인하세요.

  • 모든 POST를 application/x-www-form-urlencoded로 강제
  • body를 URL 인코딩
  • Content-Type을 덮어쓰기

###[5] 415 다음에 422가 연쇄로 뜨면 “형식”은 해결, “스키마”가 문제 415를 해결하고 나면, 이제 서버가 JSON을 파싱할 수 있게 되어 **422(스키마 검증)**가 드러나는 경우가 많습니다. 그때는 입력 스키마/필드명을 점검해야 합니다.

운영 환경에서 415를 예방하는 패턴

1) Content-Type 강제 + JSON 직렬화 단일화

팀 내에서 “요청을 만드는 유일한 함수”를 두고, 항상 json=(Python) / JSON.stringify(Node)로만 보내게 하면 415가 급감합니다.

Python 예:

import httpx

def post_responses(payload: dict, *, api_key: str) -> dict:
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
    }
    with httpx.Client(timeout=30) as client:
        r = client.post("https://api.openai.com/v1/responses", headers=headers, json=payload)
        r.raise_for_status()
        return r.json()

2) 프록시를 쓴다면 “헤더 보존”을 계약으로 못 박기

  • Content-Type, Content-Length, Authorization은 변조 금지
  • 가능하면 TLS 종단을 애플리케이션 가까이 두기

3) 에러 로깅에 반드시 요청 메타데이터를 남기기(민감정보 제외)

  • Content-Type, Content-Length, endpoint path
  • body는 원문 전체 대신 처음 200바이트 정도만(PII/키 제외)

마무리

Responses API에서 415는 대부분 “모델/파라미터 문제”가 아니라 HTTP 요청 포맷 문제입니다. 해결 순서는 단순합니다.

  1. Content-Type: application/json이 실제로 전달되는지 확인
  2. body가 실제 JSON인지 확인(json= / JSON.stringify로 통일)
  3. 멀티파트를 쓸 땐 boundary를 자동 생성하게 두고, 가능하면 JSON으로 고정
  4. 프록시/게이트웨이가 헤더를 덮어쓰지 않는지 wire-level로 검증

이 과정을 거치면 415는 대부분 즉시 해결되고, 다음 단계로 422(스키마)나 401/403(인증) 같은 “진짜 문제”가 드러납니다.