빌드노트
curl에서 100 Continue를 받았는데 업로드가 거절됐다
curl의 100 Continue 뒤에 최종 422가 와도 본문은 전부 전송될 수 있다. loopback에서 100 뒤 거절·수락, 본문 전 413, 최종 응답 누락을 비교하고 trace·http_code·송수신 바이트·종료 코드로 결과를 구별한다.
curl 상세 출력의 첫 HTTP/1.1 100 Continue를 보고 업로드 성공을 기록하면, 뒤에 온 거절 응답을 놓칠 수 있다. 100은 요청 본문을 계속 보내라는 중간 응답이다. 별도로 만든 loopback 실험에서도 100을 받은 뒤 본문 32바이트를 모두 보냈지만 최종 응답은 422였다. 결과를 집계할 때는 curl의 전송 오류 여부와 해당 요청의 최종 HTTP 상태를 함께 확인해야 한다.
RFC 9110 §15.2.1 (새 탭에서 열림)은 100을 요청의 앞부분을 받았고 아직 거절하지 않았다는 뜻으로 설명한다. 본문 처리 결과는 뒤의 최종 응답으로 알린다. 따라서 첫 상태 줄을 추출하는 집계 코드는 전송 종료 후의 상태를 기록하도록 바꿔야 한다. 보낸 바이트 수도 함께 남기면 본문 전 거절과 본문을 받은 뒤의 거절을 구별할 수 있다.
32바이트를 전부 보내고도 422를 받았다
Windows 11, Python 3.12.10, curl 8.21.0에서 인증 없는 HTTP/1.1 서버를 127.0.0.1의 임의 포트에 열었다. curl은 Expect: 100-continue와 Content-Length: 32를 보냈다. 서버는 먼저 100을 보내고, 본문을 끝까지 읽은 뒤 의도적으로 422를 반환하게 했다. 실제 서비스의 장애 기록이 아니라 응답 순서를 비교하기 위한 합성 실험이다.
다음은 curl trace에서 응답과 본문 송신 부분을 발췌한 것이다. 두 상태 줄 사이에 본문 전송이 있다.
<= Recv header, 23 bytes (0x17)
0000: HTTP/1.1 100 Continue
<= Recv header, 2 bytes (0x2)
0000:
=> Send data, 32 bytes (0x20)
0000: 0123456789abcdef0123456789abcdef
* upload completely sent off: 32 bytes
<= Recv header, 36 bytes (0x24)
0000: HTTP/1.1 422 Unprocessable Content
서버에서도 실제로 읽은 요청 본문은 32바이트였다. upload completely sent off는 curl이 본문을 다 보냈다는 관찰과 맞지만, 서버가 요청을 수락했다는 뜻으로 쓸 수는 없다. 이 요청의 처리 결과는 마지막의 422이며 응답 본문에는 synthetic body rejected가 들어 있었다.
자신의 요청에서는 상태와 송신량을 따로 남긴다
PowerShell 7과 curl.exe, 합성 본문을 받아도 되는 직접 관리하는 POST 테스트 주소가 필요하다. 아래 $url은 측정 당시의 일회성 주소다. 실험 서버는 종료했으므로 자신의 준비된 테스트 주소로 바꾼다. 이 명령은 파일 첨부용 multipart 요청이 아니라 32바이트의 원시 POST 본문을 보내며, 테스트 서버가 이 형식을 받을 수 있어야 한다.
쓰기 가능한 빈 진단 폴더에서 실행하면 입력 파일을 만들고, 전송 후 상태·송신량·curl 종료 코드를 표시한다. payload.bin, upload.trace, response.txt를 쓰므로 기존 자료가 있는 폴더는 피한다.
$url = 'http://127.0.0.1:17643/continue-reject'
[IO.File]::WriteAllBytes((Join-Path $PWD 'payload.bin'), [Text.Encoding]::ASCII.GetBytes('0123456789abcdef0123456789abcdef'))
curl.exe -q --noproxy '*' --http1.1 --connect-timeout 1 --max-time 2 `
--expect100-timeout 1 -sS -H 'Expect: 100-continue' `
--data-binary '@payload.bin' --trace-ascii upload.trace -o response.txt `
-w 'http=%{http_code} uploaded=%{size_upload}\n' $url
$rc = $LASTEXITCODE
"curl_exit=$rc"
Get-Content ./upload.trace | Select-String '^[0-9a-f]{4}: HTTP/1\.1|Send data'
422를 반환한 조건에서 실제 출력은 다음과 같았다. trace 전체 대신 응답 상태 줄과 본문 송신 줄만 골라 표시했다.
http=422 uploaded=32
curl_exit=0
0000: HTTP/1.1 100 Continue
=> Send data, 32 bytes (0x20)
0000: HTTP/1.1 422 Unprocessable Content
-w는 전송 후 정보를 출력한다. http_code는 마지막 전송에서 받은 응답 코드이고, size_upload는 헤더를 제외하고 보낸 본문 바이트 수다. 이 두 값과 별도로 curl 직후의 $LASTEXITCODE를 보관했다. 이 정의는 실행한 버전의 curl 8.21.0 write-out 문서 (새 탭에서 열림)에서 확인했다.
위 명령에는 HTTP 오류를 curl 오류로 바꾸는 실패 옵션을 넣지 않았다. 그래서 422를 끝까지 받아도 종료 코드는 0이었다. 종료 코드 0과 최종 HTTP 422를 함께 읽어야 거절을 놓치지 않는다. response.txt에는 서버가 보낸 거절 설명이 남는다. 기본 curl의 HTTP 오류 처리와 trace 옵션은 공식 매뉴얼 (새 탭에서 열림)에서도 설명한다.
기존 업로드 명령을 조사한다면 본문 형식과 메서드는 그 API의 계약에 맞게 유지하고, trace와 -w 출력을 추가한다. trace에는 송수신 본문과 헤더가 기록되므로 이 실험은 합성 데이터만 사용했다. -v와 --trace-ascii는 함께 쓰지 않는다. curl trace 설명 (새 탭에서 열림)
본문 전에 거절하면 송신량도 달랐다
같은 32바이트 입력으로 서버의 처리 조건을 바꿨다. 성공 조건은 본문을 모두 읽고 200을 반환했다. 조기 거절 조건은 선언된 길이가 합성 제한인 16바이트를 넘으면 100을 보내지 않고 즉시 413을 반환했다. 마지막 조건은 100과 본문 수신까지만 진행하고 최종 응답을 생략한 고장 주입이다.
다음 숫자는 네 요청을 각각 실행한 결과다. 서버 수신량은 서버가 실제로 읽은 요청 본문 바이트 수이며, 응답 본문의 길이와는 다르다.
서버 조건 받은 상태 순서 http_code curl 종료 curl 송신 서버 수신
본문 후 거절 100 → 422 422 0 32 32
본문 후 수락 100 → 200 200 0 32 32
본문 전 거절 413 413 0 0 0
최종 응답 생략 100 100 28 32 32
본문 전 거절에서는 curl trace에 Send data가 없었고 uploaded=0이었다. 서버도 응답을 보낸 뒤 연결의 나머지 데이터를 읽어 클라이언트의 연결 종료까지 확인했으며 본문은 0바이트였다. 단순히 서버가 본문 읽기를 생략한 것을 수신량 0으로 기록한 결과가 아니다.
RFC 9110 §10.1.1 (새 탭에서 열림)에 따르면, 서버는 메서드·대상 주소·헤더만으로 결과를 결정할 수 있으면 곧바로 최종 응답을 보낼 수 있다. 따라서 이 413 앞에 100이 없는 것은 응답 누락이 아니다. 이번 조건에서는 불필요한 본문 전송이 시작되기 전에 거절을 받았다.
다만 Expect가 항상 송신량 0을 보장하지는 않는다. --expect100-timeout 1은 100을 기다리는 시간을 정한다. 공식 문서상 그 대기가 끝나면 curl은 본문 전송을 진행한다. 전체 전송 제한은 따로 지정한 --max-time 2다. 본문 전 거절은 즉시 응답한 조건만 측정했으며, 100 대기 만료 뒤 전송되는 조건은 실행하지 않았다. Expect 대기 옵션 (새 탭에서 열림)
마지막으로 보인 100도 완료 상태가 될 수 없다
최종 응답을 생략한 요청은 본문 32바이트를 보낸 뒤 전체 전송 제한에 걸렸다. 출력은 http=100 uploaded=32, curl_exit=28이었다. http_code라는 이름의 값이 나와도 전송 실패 중에는 중간 응답만 남아 있을 수 있었다. 마지막 숫자가 존재한다는 이유로 업로드가 완료됐다고 처리하면 안 된다.
집계할 때는 먼저 curl 종료 코드로 시간 초과 같은 전송 오류를 확인한다. 다음으로 해당 요청의 최종 응답이 있는지와 API가 허용한 성공 상태인지 판단한다. 이번의 422·413은 거절이고, 100만 받은 시간 초과는 최종 결과를 받지 못한 경우다. 이 셋을 성공 건수에 더하지 않는다.
본문 바이트는 거절 시점을 해석하는 근거로 쓴다. 32바이트를 다 보냈다는 사실은 서버의 수락이나 저장 완료를 보증하지 않는다. 서버 로그를 볼 수 있다면 같은 요청의 실제 본문 수신량과 처리 결과를 대조한다. 여러 요청의 로그를 합친 뒤 마지막 상태 하나를 고르면 다른 요청의 결과를 가져올 수 있으므로 요청별로 기록한다.
이번 확인은 직접 연결한 HTTP/1.1 요청 네 개와 작은 ASCII 본문에 한정된다. 200을 보낸 서버도 본문을 메모리에서 비교했을 뿐 파일로 저장하지 않았다. 실제 업로드 서비스의 저장·트랜잭션, TLS, 프록시, 인증, 리다이렉트, HTTP/2·3, 큰 본문의 부분 전송과 성능은 검증하지 않았다. 자기 서비스의 성공 판정에는 그 API의 최종 응답 계약과 필요한 저장 확인을 적용해야 한다.