Published on

Python OpenAI SDK 400 invalid_json 원인과 해결

Authors

서버가 400 invalid_json을 돌려줄 때 가장 답답한 지점은 “내가 JSON을 보냈는데 왜 JSON이 아니라고 하지?”입니다. Python OpenAI SDK를 쓰면 보통 SDK가 직렬화를 해주기 때문에 더 혼란스럽죠. 하지만 실제 현장에서는 요청 바디가 JSON이 아닌 형태로 전송되거나, JSON으로 직렬화 불가능한 객체가 섞이거나, 헤더/미들웨어가 바디를 건드리면서 깨지는 경우가 생각보다 많습니다.

이 글에서는 Python OpenAI SDK(OpenAI python 패키지) 기준으로 400 invalid_json을 만드는 대표 원인과, 로그/재현/수정 패턴을 한 번에 정리합니다.

1) invalid_json이 의미하는 것

OpenAI API 계열에서 invalid_json은 보통 다음 중 하나입니다.

  • HTTP 요청의 본문(body)이 JSON 파싱에 실패
  • Content-Type: application/json인데 실제 body가 JSON이 아님
  • body는 JSON이지만 깨진 인코딩/잘린 바이트로 인해 파싱 실패
  • SDK 바깥(프록시/게이트웨이/미들웨어)이 body를 변형

즉, “스키마가 틀렸다(필드 누락)”가 아니라 아예 JSON 파서가 읽을 수 없는 상태에 가깝습니다.

> 스키마/필드 문제는 보통 invalid_request_error나 “unknown field …” 같은 메시지로 나타납니다.

2) 가장 흔한 원인 TOP 7

원인 1) json=data=를 혼용하거나, 문자열 JSON을 또 감싸서 보냄

SDK를 쓰지 않고 requests로 직접 호출할 때 특히 흔합니다.

  • data=에 dict를 넣으면 application/x-www-form-urlencoded로 가거나, 문자열 변환이 일어나 JSON이 아니게 됩니다.
  • json.dumps()로 만든 문자열을 다시 json=에 넣으면 문자열 자체가 JSON 값이 되어 서버가 기대한 객체 구조와 어긋나거나, 중간에서 깨질 수 있습니다.
import requests, json

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

# 잘못된 예: data= 로 dict 전달 (JSON 아님)
requests.post("https://api.openai.com/v1/responses", data=payload)

# 잘못된 예: JSON 문자열을 json=로 전달 (이중 처리)
requests.post("https://api.openai.com/v1/responses", json=json.dumps(payload))

# 올바른 예
requests.post("https://api.openai.com/v1/responses", json=payload)

Python OpenAI SDK를 쓴다면 직접 requests를 섞지 말고, SDK 호출만으로 통일하는 게 안전합니다.

원인 2) 직렬화 불가능한 객체가 섞임 (bytes, datetime, set, Pydantic 모델 등)

SDK는 내부적으로 JSON 직렬화를 합니다. 그런데 입력에 다음이 섞이면 직렬화 단계에서 깨지거나(혹은 중간 변환에서) 이상한 문자열이 들어가 문제가 됩니다.

  • bytes (예: 파일 내용을 그대로 넣음)
  • datetime (ISO 변환 없이 넣음)
  • set
  • 커스텀 클래스 인스턴스
from datetime import datetime
from openai import OpenAI

client = OpenAI()

bad_payload = {
    "when": datetime.utcnow(),  # JSON 직렬화 불가
    "tags": {"a", "b"},       # set 직렬화 불가
}

# 이런 값을 그대로 model input에 넣으면 문제가 됩니다.

해결은 요청 직전 “JSON-safe”로 변환하는 것입니다.

import json
from datetime import datetime

def to_json_safe(obj):
    if isinstance(obj, datetime):
        return obj.isoformat()
    if isinstance(obj, set):
        return list(obj)
    if isinstance(obj, (bytes, bytearray)):
        return obj.decode("utf-8", errors="replace")
    raise TypeError(f"Not JSON serializable: {type(obj)}")

payload = {"when": datetime.utcnow(), "tags": {"a", "b"}}

# 직렬화 검증(여기서 예외가 나면 API 호출 전에 잡을 수 있음)
json.dumps(payload, default=to_json_safe)

원인 3) 스트리밍 응답을 프록시/미들웨어가 잘못 버퍼링하며 요청 바디까지 망가뜨림

특히 FastAPI/Starlette, Nginx, ALB, Cloudflare 같은 계층이 있을 때, “응답 스트리밍” 문제로만 보이지만 실제로는 요청 바디가 중간에서 잘리거나 재인코딩되어 invalid_json이 나기도 합니다.

  • gzip/브로틀리 압축 설정
  • request body size 제한
  • 프록시가 chunked encoding 처리 중 오류

인프라 레벨 오류 진단 습관은 다른 5xx에서도 그대로 통합니다. 비슷한 접근은 이 글도 참고가 됩니다: Cloudflare 520·521, Nginx·ALB 로그로 30분 진단

원인 4) Content-Type이 깨짐 (특히 멀티파트/파일 업로드를 억지로 JSON에 넣는 경우)

Responses API는 보통 JSON을 받습니다. 그런데 파일/이미지/오디오 등을 다루면서 멀티파트를 섞거나, 반대로 멀티파트여야 하는데 JSON으로 보내면 헤더/바디 불일치가 납니다.

415 Unsupported Media Type과 함께 엮이는 경우도 많습니다. 헤더/미디어타입 이슈가 의심되면 함께 확인하세요: OpenAI Responses API 415 Unsupported Media Type 해결

원인 5) 유니코드/인코딩 문제로 바디가 깨짐

대부분은 UTF-8이지만, 다음 상황에서 깨질 수 있습니다.

  • 바디를 직접 bytes로 만들고 잘못된 인코딩으로 .encode()
  • 로그/미들웨어가 바디를 latin-1 같은 것으로 재해석

해결: 바디를 직접 bytes로 만들지 말고, dict로 두고 JSON 직렬화는 라이브러리/SDK에 맡기세요.

원인 6) 요청 바디를 로깅하려고 request.body()를 여러 번 읽어서 빈 바디가 됨

FastAPI/Starlette에서 request body를 한번 읽으면 스트림이 소모됩니다. 로깅 미들웨어가 바디를 읽어버리면, 실제 핸들러에서는 빈 바디를 보내게 되고(혹은 다른 값), upstream에서 JSON 파싱 실패가 납니다.

  • “로깅 추가했더니 invalid_json이 생김” 패턴이면 거의 이겁니다.

해결: body를 캐싱하거나, 프레임워크가 제공하는 request state에 저장하는 방식으로 한 번만 읽게 하세요.

원인 7) 모델 입력에 거대한 문자열/바이너리 덩어리를 그대로 넣어 전송 중 잘림

네트워크/프록시의 client_max_body_size 같은 제한에 걸리면 요청이 중간에서 잘릴 수 있고, 잘린 JSON은 파싱 불가라 invalid_json이 됩니다.

  • Nginx: client_max_body_size
  • ALB/Cloudflare: 최대 바디 제한
  • 앱 서버: Gunicorn/uvicorn 제한

3) 재현 가능한 최소 코드: “정상 요청” 템플릿

Python OpenAI SDK(최신 계열)에서 가장 단순한 정상 요청을 기준점으로 두세요.

from openai import OpenAI

client = OpenAI()  # OPENAI_API_KEY 환경변수 사용

resp = client.responses.create(
    model="gpt-4.1-mini",
    input="JSON 문제 디버깅용 핑",
)

print(resp.output_text)

여기서부터 조금씩 기능을 추가하면서 문제가 생기는 지점을 찾는 방식이 가장 빠릅니다.

4) invalid_json을 “호출 전에” 잡는 방어 코드

SDK를 쓰더라도, 실제로는 여러분이 넣는 input, tools, metadata 등이 복잡해지면서 직렬화 문제가 생깁니다. 호출 직전에 payload를 JSON으로 덤프해보는 검증 단계를 넣으면, 원인을 애플리케이션 로그에서 바로 확인할 수 있습니다.

import json
from openai import OpenAI

client = OpenAI()

def assert_jsonable(x):
    try:
        json.dumps(x)
    except TypeError as e:
        raise ValueError(f"Request payload is not JSON-serializable: {e}")

tools = [
    {
        "type": "function",
        "function": {
            "name": "sum_numbers",
            "description": "sum two numbers",
            "parameters": {
                "type": "object",
                "properties": {
                    "a": {"type": "number"},
                    "b": {"type": "number"},
                },
                "required": ["a", "b"],
            },
        },
    }
]

payload = {
    "model": "gpt-4.1-mini",
    "input": "a=1, b=2 더해줘",
    "tools": tools,
}

assert_jsonable(payload)

resp = client.responses.create(**payload)
print(resp.output_text)

여기서 assert_jsonable()이 실패하면 서버까지 갈 필요도 없습니다.

5) 프레임워크(예: FastAPI)에서 생기는 케이스: body 로깅 미들웨어

다음은 “요청 바디 로깅”을 하다가 스트림을 소모해버리는 전형적인 안티패턴 예시입니다.

# 안티패턴: request.body()를 읽고 버림
from fastapi import FastAPI, Request

app = FastAPI()

@app.middleware("http")
async def log_body(request: Request, call_next):
    body = await request.body()
    print("body=", body[:200])
    response = await call_next(request)
    return response

해결은 프레임워크 권장 방식으로 body를 재주입하거나(Starlette의 receive 채널 재정의), 애초에 바디 전체 로깅을 피하고 필요한 필드만 로깅하는 것입니다. 최소한으로는 OpenAI 호출 직전의 “구성 파라미터”만 로깅하세요(키/민감정보 제외).

6) 운영에서의 디버깅 체크리스트

6.1 요청을 “있는 그대로” 관찰하기

  • 앱 서버에서 OpenAI로 나가는 요청을 프록시한다면, 프록시가 바디를 변형하지 않는지 확인
  • 가능하면 OpenAI 호출 부분만 로컬에서 직접 실행해 비교

6.2 바디 크기와 제한 확인

  • Nginx client_max_body_size
  • Ingress Controller 제한
  • 서버 프레임워크의 최대 요청 크기

특히 Kubernetes/Ingress 환경에서 간헐적으로만 발생한다면 타임아웃/버퍼링/keepalive 문제와 함께 봐야 합니다. LLM 스트리밍/프록시 튜닝 관점은 이 글이 도움이 됩니다: Kubernetes LLM 서비스 502 504 간헐 장애와 스트리밍 끊김을 끝내는 NGINX Ingress와 Gunicorn Uvicorn 실전 튜닝

6.3 재시도는 “원인에 따라”

invalid_json은 대개 클라이언트 버그라서 무작정 재시도해도 해결되지 않습니다. 다만 프록시/네트워크가 바디를 간헐적으로 잘라먹는 환경이라면 재시도가 증상을 완화할 수는 있습니다.

재시도/백오프 자체는 429 대응에서 많이 다루지만, 구현 패턴은 그대로 재사용 가능합니다: OpenAI API 429 폭탄 대응 실전 가이드 지수 백오프 큐잉 토큰 버짓으로 비용과 지연을 함께 줄이기

7) 결론: “JSON은 맞는데 invalid_json”을 끝내는 방법

400 invalid_json은 대부분 아래 3가지만 지키면 사라집니다.

  1. 요청 바디를 dict로 유지하고, 직렬화는 SDK/라이브러리에 맡긴다(직접 bytes/문자열로 만들지 않기).
  2. OpenAI 호출 직전 json.dumps(payload)JSON 직렬화 가능 여부를 사전 검증한다.
  3. 프록시/미들웨어/로깅이 request body를 읽어 소모하거나 변형하지 않는지 점검한다(특히 FastAPI 미들웨어, Nginx/Ingress 설정).

이 3단계로도 해결이 안 되면, 다음 정보를 모아두면 원인 추적이 빨라집니다.

  • 실패한 요청의 payload(민감정보 제거)와 json.dumps() 결과
  • 실패 시점의 프록시/Ingress/Nginx 로그
  • 요청 바디 크기, Content-Type, 전송 방식(chunked 여부)

이렇게 접근하면 “모델/파라미터가 문제”가 아니라 “전송 계층의 JSON 파싱 실패”라는 본질에 맞게, 짧은 시간 안에 원인을 고정할 수 있습니다.