1년 넘게 잘 동작하던 RestTemplate을 통해 OpenAI API를 call 하는 로직에서 아래와 같은 400 에러가 갑자기 뜨게 되었다.
BadRequestError: Error code: 400 - {'error': {'message': 'you must provide a model parameter', 'type': 'invalid_request_error', 'param': null, 'code': null}}
처음엔 외부 API 서버 측 일시적인 문제겠거니... 하고 우선 모니터링에 더 집중했다. (건드린 사람이 없었고 코드에서도 이상이 없어보였으니까!!!)
그런데 잠깐... OpenAI status도 특이사항 없었고 여러 가지 상황을 가정해보았는데 모두 말이 안 되었다. 400이면 클라이언트(=나) 잘못인데... 다시 분석해보았다.
메시지 그대로다. 정말로 model을 null로 보내주거나 아예 보내주지 않으면 해당 메시지가 뜨게 된다. OpenAI에서 지원하지 않는 모델명을 설정하여 보내줄 경우 다른 메시지가 뜬다.
header에 Content-Type을 아예 누락시키거나 application/json으로 정확히 설정하지 않으면 해당 메시지가 뜨게 된다 (오타도 조심하자).
리서치 및 실험 결과 위 두 가지 케이스를 발견하였는데 더 있을 수도...
여기! 를 검색하면 원인 코드를 좀 더 빠르게 확인할 수 있을 것이다.
model을 명시적으로 보내주지 않는 케이스가 아니라면 Content-Type을 header에 설정하는 부분을 살펴볼 필요가 있다. (Spring Boot 기준 보통 RestTemplate을 bean으로 관리하거나 서비스 생성자 레벨에서 초기화하여 사용할테니 그 부분을 유심히 살펴보면 될 것 같다.) 본인은 아래와 같이 RestTemplate를 초기화하여 사용했었다.
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(API_KEY);
this.restTemplate = restTemplateBuilder
.rootUri("https://api.openai.com")
.interceptors((request, body, execution) -> {
request.getHeaders().addAll(headers);
return execution.execute(request, body);
})
...
.build();
대충 봤을 때는 이상 없어보이는데 결론부터 이야기하자면 Content-Type application/json
이 두 번 설정된다. 그 이유가 궁금하여 내부 코드를 살짝 열어보았다.
RestTemplateBuilder.java
public RestTemplate build() {
return configure(new RestTemplate());
}
RestTemplateBuilder를 통해 RestTemplate()을 만들게 되는 경우 RestTemplate() 기본 생성자를 호출한다.
RestTemplate.java
public RestTemplate() {
...
if (jackson2Present) {
this.messageConverters.add(new MappingJackson2HttpMessageConverter());
}
...
}
RestTemplate 생성자의 일부다. jackson2Present가 true인 조건은 ObjectMapper 클래스의 존재 여부인데 본인 프로젝트에서는 사용하고 있었기 때문에 messageConverters
에 MappingJackson2HttpMessageConverter
가 추가된다.
MappingJackson2HttpMessageConverter.java
/**
* Construct a new {@link MappingJackson2HttpMessageConverter} with a custom {@link ObjectMapper}.
* You can use {@link Jackson2ObjectMapperBuilder} to build it easily.
* @see Jackson2ObjectMapperBuilder#json()
*/
public MappingJackson2HttpMessageConverter(ObjectMapper objectMapper) {
super(objectMapper, MediaType.APPLICATION_JSON, new MediaType("application", "*+json"));
}
protected AbstractJackson2HttpMessageConverter(ObjectMapper objectMapper, MediaType... supportedMediaTypes) {
this(objectMapper);
setSupportedMediaTypes(Arrays.asList(supportedMediaTypes));
}
MappingJackson2HttpMessageConverter 생성자를 따라가보면 supportedMediaTypes에 application/json
, application.*+json
이 추가되는 걸 볼 수 있다. 이제 이 값들이 어떻게 사용되는지 알아보자.
RestTemplate.java
@Override
@SuppressWarnings("unchecked")
public void doWithRequest(ClientHttpRequest httpRequest) throws IOException {
super.doWithRequest(httpRequest);
Object requestBody = this.requestEntity.getBody();
if (requestBody == null) {
HttpHeaders httpHeaders = httpRequest.getHeaders();
HttpHeaders requestHeaders = this.requestEntity.getHeaders();
if (!requestHeaders.isEmpty()) {
requestHeaders.forEach((key, values) -> httpHeaders.put(key, new ArrayList<>(values)));
}
if (httpHeaders.getContentLength() < 0) {
httpHeaders.setContentLength(0L);
}
}
else {
Class<?> requestBodyClass = requestBody.getClass();
Type requestBodyType = (this.requestEntity instanceof RequestEntity ?
((RequestEntity<?>)this.requestEntity).getType() : requestBodyClass);
HttpHeaders httpHeaders = httpRequest.getHeaders();
HttpHeaders requestHeaders = this.requestEntity.getHeaders();
MediaType requestContentType = requestHeaders.getContentType();
for (HttpMessageConverter<?> messageConverter : getMessageConverters()) {
if (messageConverter instanceof GenericHttpMessageConverter) {
GenericHttpMessageConverter<Object> genericConverter =
(GenericHttpMessageConverter<Object>) messageConverter;
if (genericConverter.canWrite(requestBodyType, requestBodyClass, requestContentType)) {
if (!requestHeaders.isEmpty()) {
requestHeaders.forEach((key, values) -> httpHeaders.put(key, new ArrayList<>(values)));
}
logBody(requestBody, requestContentType, genericConverter);
genericConverter.write(requestBody, requestBodyType, requestContentType, httpRequest);
return;
}
}
else if (messageConverter.canWrite(requestBodyClass, requestContentType)) {
if (!requestHeaders.isEmpty()) {
requestHeaders.forEach((key, values) -> httpHeaders.put(key, new ArrayList<>(values)));
}
logBody(requestBody, requestContentType, messageConverter);
((HttpMessageConverter<Object>) messageConverter).write(
requestBody, requestContentType, httpRequest); // 여기!
return;
}
}
String message = "No HttpMessageConverter for " + requestBodyClass.getName();
if (requestContentType != null) {
message += " and content type \"" + requestContentType + "\"";
}
throw new RestClientException(message);
}
}
REST API 요청을 하게 되면 결국 위 메소드를 호출하게 되는데 여기서 messageConverter의 write 메소드에 집중해보자.
AbstractHttpMessageConverter.java
@Override
public final void write(final T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
final HttpHeaders headers = outputMessage.getHeaders();
addDefaultHeaders(headers, t, contentType); // 여기!
if (outputMessage instanceof StreamingHttpOutputMessage) {
StreamingHttpOutputMessage streamingOutputMessage = (StreamingHttpOutputMessage) outputMessage;
streamingOutputMessage.setBody(outputStream -> writeInternal(t, new HttpOutputMessage() {
@Override
public OutputStream getBody() {
return outputStream;
}
@Override
public HttpHeaders getHeaders() {
return headers;
}
}));
}
else {
writeInternal(t, outputMessage);
outputMessage.getBody().flush();
}
}
/**
* Add default headers to the output message.
* <p>This implementation delegates to {@link #getDefaultContentType(Object)} if a
* content type was not provided, set if necessary the default character set, calls
* {@link #getContentLength}, and sets the corresponding headers.
* @since 4.2
*/
protected void addDefaultHeaders(HttpHeaders headers, T t, @Nullable MediaType contentType) throws IOException {
if (headers.getContentType() == null) {
MediaType contentTypeToUse = contentType;
if (contentType == null || !contentType.isConcrete()) {
contentTypeToUse = getDefaultContentType(t); // 여기!
}
else if (MediaType.APPLICATION_OCTET_STREAM.equals(contentType)) {
MediaType mediaType = getDefaultContentType(t);
contentTypeToUse = (mediaType != null ? mediaType : contentTypeToUse);
}
if (contentTypeToUse != null) {
if (contentTypeToUse.getCharset() == null) {
Charset defaultCharset = getDefaultCharset();
if (defaultCharset != null) {
contentTypeToUse = new MediaType(contentTypeToUse, defaultCharset);
}
}
headers.setContentType(contentTypeToUse); // 여기!
}
}
if (headers.getContentLength() < 0 && !headers.containsKey(HttpHeaders.TRANSFER_ENCODING)) {
Long contentLength = getContentLength(t, headers.getContentType());
if (contentLength != null) {
headers.setContentLength(contentLength);
}
}
}
getDefaultContentType() 를 통해 contentType을 가져와서 헤더에 Content-Type을 설정해준다.
HttpHeaders.java
/**
* Set the {@linkplain MediaType media type} of the body,
* as specified by the {@code Content-Type} header.
*/
public void setContentType(@Nullable MediaType mediaType) {
if (mediaType != null) {
Assert.isTrue(!mediaType.isWildcardType(), "Content-Type cannot contain wildcard type '*'");
Assert.isTrue(!mediaType.isWildcardSubtype(), "Content-Type cannot contain wildcard subtype '*'");
set(CONTENT_TYPE, mediaType.toString());
}
else {
remove(CONTENT_TYPE);
}
}
@Override
public void set(String headerName, @Nullable String headerValue) {
this.headers.set(headerName, headerValue);
}
final MultiValueMap<String, String> headers; // 여기!
그리고 headers는 MultiValueMap이었다. 따라서 중복 값이 들어갈 수 있었던 것이다. 실제로 postman에서도 Content-Type을 중복으로 요청하였더니 진짜 동일한 메시지가 뜨는 것을 확인할 수 있었다!!!
오랫동안 정상 동작했던 코드라도 너무 믿지 말고 외부 Call이 끼어있다면 다시 한 번 확인해보자. 400 에러면 본인 잘못일 확률이 매우 높다...
오류 찾기 전까지는 매우 답답했지만 원인을 알아내고 RestTemplate 내부 코드를 열어보는 과정은 재미있었다 🙂