- Published on
OpenAI Batch API 429·큐 지연·부분실패 재시도 전략
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 대량 추론/생성 작업을 돌릴 때 Batch API는 비용과 운영 복잡도를 동시에 낮춰주는 강력한 선택지입니다. 하지만 실제로 붙여보면 곧바로 세 가지 벽을 만납니다.
429(Rate limit / Too Many Requests)로 인한 업로드/조회 실패- 배치가
in_progress에 오래 머무는 큐 지연(queueing delay) - 일부 요청만 실패하는 **부분 실패(partial failure)**와 재시도 설계
이 글은 “요청을 더 천천히 보내세요” 같은 원론이 아니라, Batch API 파이프라인을 관측 가능하게 만들고, 실패를 분류한 뒤, 부분 실패만 안전하게 재시도하는 운영 패턴을 다룹니다. (예시는 Node.js/TypeScript 중심)
Batch API의 실패를 ‘요청 단계’로 나눠서 보기
Batch API는 보통 다음 단계로 나뉩니다.
- 입력 파일(JSONL) 업로드
- 배치 생성(파일 참조)
- 배치 처리(큐 대기 → 실행)
- 결과 파일 다운로드 및 파싱
여기서 429가 어디서 터졌는지에 따라 대응이 달라집니다.
- 업로드/배치 생성 API 호출에서 429: 클라이언트 레이트리밋/동시성 제어 문제
- 배치 처리 자체가 지연: 서버측 큐/용량 문제(대부분은 “기다림”이 정답)
- 결과에서 일부 항목만 실패: 입력 품질/모델 제한/토큰 초과/스키마 오류 등 “데이터 문제” 가능성
즉, 429 하나로 뭉뚱그려 재시도하면 오히려 더 큰 혼잡을 만들 수 있습니다.
429를 두 종류로 분리: 즉시 재시도 vs 백오프 후 재시도
1) 업로드/생성/조회 호출에서의 429
이 구간은 전형적인 HTTP 레이트리밋입니다. 전략은 간단합니다.
- 클라이언트 동시성 제한(예: 배치 생성은 계정/프로젝트 당 1~N개만 동시)
- 지수 백오프 + 지터(jitter)
- 가능하면 응답 헤더의 rate limit 정보를 활용(제공되는 경우)
아래는 OpenAI SDK를 쓰는 Node.js에서 429/5xx에만 백오프 재시도를 거는 예시입니다.
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function backoffMs(attempt: number) {
// 0: 500~1000ms, 1: 1000~2000ms, 2: 2000~4000ms ...
const base = 500 * Math.pow(2, attempt);
const jitter = Math.floor(Math.random() * base);
return base + jitter;
}
async function withRetry<T>(fn: () => Promise<T>, max = 6): Promise<T> {
let lastErr: unknown;
for (let i = 0; i < max; i++) {
try {
return await fn();
} catch (e: any) {
lastErr = e;
const status = e?.status ?? e?.response?.status;
// 429 + 일시적 서버 오류만 재시도
if (![429, 500, 502, 503, 504].includes(status)) throw e;
await sleep(backoffMs(i));
}
}
throw lastErr;
}
// 예: 배치 생성 호출을 재시도 래핑
async function createBatch(inputFileId: string) {
return withRetry(() =>
client.batches.create({
input_file_id: inputFileId,
endpoint: "/v1/responses",
completion_window: "24h",
})
);
}
핵심은 “모든 에러를 재시도”가 아니라, 재시도해도 의미 있는 에러만 재시도하는 것입니다.
2) 배치 처리 중의 429/지연
배치가 in_progress에서 오래 머무는 경우는 대부분 “내가 더 요청을 덜 보내면 해결”되는 문제가 아닙니다. Batch는 본질적으로 큐 기반이며, 시스템 상황에 따라 지연이 발생합니다.
이때 필요한 건 재시도가 아니라:
- 상태 폴링 주기 최적화(너무 자주 조회하면 조회 자체가 429를 유발)
- 타임아웃/에스컬레이션 기준(예: 2시간 이상 정체면 알림)
- 배치 크기/분할 전략(너무 큰 배치는 꼬였을 때 회복이 느림)
큐 지연을 운영 지표로 만들기
큐 지연은 “느리다”가 아니라 “측정 가능한 값”입니다. 최소한 아래를 기록하세요.
batch_created_atbatch_started_at(가능하면 상태 변화를 통해 추정)batch_completed_atqueue_delay = started - createdrun_time = completed - started
상태 조회는 10초, 30초 같은 고정 주기보다 점진적 폴링이 유리합니다.
async function pollBatch(batchId: string) {
const scheduleSec = [5, 10, 20, 40, 60, 120, 300];
let idx = 0;
while (true) {
const batch = await withRetry(() => client.batches.retrieve(batchId));
if (batch.status === "completed" || batch.status === "failed" || batch.status === "cancelled") {
return batch;
}
const waitSec = scheduleSec[Math.min(idx, scheduleSec.length - 1)];
idx++;
await sleep(waitSec * 1000);
}
}
이렇게 하면 조회 트래픽을 줄여 조회 429를 피하고, 동시에 장기 정체를 감지할 수 있습니다.
부분 실패: “배치 실패”와 “아이템 실패”를 분리
Batch API에서 흔한 오해가 있습니다.
- 배치가
completed면 전부 성공이다? → 아닙니다. 결과 파일 안에 실패가 섞일 수 있습니다. - 배치가
failed면 전부 실패다? → 아닐 수도 있습니다(일부는 처리되었을 수 있음).
따라서 운영 관점에서는 다음 3단계로 분리해야 합니다.
- 배치 레벨 상태(completed/failed/cancelled)
- 아이템 레벨 결과(각 request의 success/error)
- 재시도 후보 집합(재시도 가치가 있는 error만)
결과 JSONL 파싱과 실패 추출
Batch 결과는 보통 JSONL 형태로 내려오며, 각 줄에 custom_id(내가 넣은 식별자)와 응답/에러가 들어 있습니다. 아래 코드는 결과 파일을 받아서 실패한 custom_id를 모으는 예시입니다.
import fs from "node:fs";
import readline from "node:readline";
type BatchLine = {
custom_id: string;
response?: any;
error?: { message?: string; type?: string; code?: string };
};
async function collectFailures(resultJsonlPath: string) {
const failures: { custom_id: string; error: BatchLine["error"]; raw: BatchLine }[] = [];
const rl = readline.createInterface({
input: fs.createReadStream(resultJsonlPath, { encoding: "utf-8" }),
crlfDelay: Infinity,
});
for await (const line of rl) {
if (!line.trim()) continue;
const obj = JSON.parse(line) as BatchLine;
if (obj.error) failures.push({ custom_id: obj.custom_id, error: obj.error, raw: obj });
}
return failures;
}
여기서 중요한 건 custom_id를 처음부터 재시도 키로 설계하는 것입니다.
doc:123#chunk:04#v1같은 형태로 “원본 작업 단위”를 추적 가능하게- 동일 작업을 재시도할 때는
attempt를 커스텀 메타로 남기거나 custom_id에 포함
부분 실패 재시도: ‘재시도 가능한 실패’만 다시 만든다
부분 실패 재시도에서 가장 흔한 사고는 다음입니다.
- 실패 몇 개 때문에 전체 배치를 그대로 재실행 → 비용/시간 폭증
- 원인 불명이라 무한 재시도 → 계속 같은 입력이 같은 에러를 냄
따라서 재시도는 반드시 분류 기반이어야 합니다.
재시도 분류 룰(실전용)
아래는 운영에서 유효한 보수적 기준입니다.
- 재시도 권장
- 일시적 네트워크/서버 오류(5xx)
- 429(단, 동일 배치/동일 시점에 과도한 폴링 등 원인이 명확하면 먼저 원인 제거)
- 타임아웃 성격의 오류
- 재시도 비권장(입력 수정 필요)
- 토큰 초과/컨텍스트 길이 초과
- 스키마 불일치(예: Responses API 포맷 오류)
- 안전 정책/권한/잘못된 모델명 등
Responses API를 쓰는 경우, 출력 포맷 문제로 400 invalid_output_text 같은 오류가 재시도해도 반복될 수 있습니다. 이런 케이스는 “재시도”가 아니라 “요청 구조 수정”이 먼저입니다. 관련해서는 OpenAI Responses API 400 invalid_output_text 해결 가이드를 함께 참고하면 원인 분리가 빨라집니다.
실패 아이템만 모아 새 배치 JSONL 만들기
처음 입력 JSONL을 custom_id -> request body로 복원할 수 있어야 실패만 재구성할 수 있습니다. 가장 쉬운 방법은:
- 원본 입력을 S3/DB에 저장
- 또는 생성 시점에
custom_id를 키로 request payload를 별도 저장
예시로, Map에 원본을 가지고 있다고 가정하고 실패만 재작성합니다.
type RequestPayload = {
custom_id: string;
method: "POST";
url: "/v1/responses";
body: any;
};
function isRetryable(err: { message?: string; type?: string; code?: string } | undefined) {
const msg = (err?.message ?? "").toLowerCase();
const code = (err?.code ?? "").toLowerCase();
if (code.includes("rate_limit") || msg.includes("429")) return true;
if (msg.includes("timeout") || msg.includes("temporarily") || msg.includes("overloaded")) return true;
// 입력/스키마/토큰류는 재시도 비권장
if (msg.includes("context") && msg.includes("length")) return false;
if (msg.includes("invalid") || msg.includes("schema")) return false;
// 기본은 보수적으로 false
return false;
}
function buildRetryJsonl(
failures: { custom_id: string; error: any }[],
originalById: Map<string, RequestPayload>
) {
const lines: string[] = [];
for (const f of failures) {
if (!isRetryable(f.error)) continue;
const orig = originalById.get(f.custom_id);
if (!orig) continue;
// attempt를 body.metadata에 넣는 등 추적성 강화
const body = {
...orig.body,
metadata: { ...(orig.body?.metadata ?? {}), retry_of: f.custom_id, retry_at: new Date().toISOString() },
};
lines.push(JSON.stringify({ ...orig, body }));
}
return lines.join("\n") + (lines.length ? "\n" : "");
}
이렇게 만들면 재시도 배치는 “실패한 일부”만 포함하므로 비용과 지연이 크게 줄어듭니다.
429를 줄이는 배치 분할/업로드 전략
실제로 429는 “너무 많은 요청”뿐 아니라 “너무 큰 단위의 작업을 한 번에 밀어 넣는 습관”에서 자주 발생합니다.
1) 배치를 적당한 크기로 쪼개기
- 한 배치에 수만 건을 넣는 대신, 수천 건 단위로 분할
- 분할 기준은 “실패했을 때 복구 비용”과 “큐 지연”의 균형
특히 부분 실패 재시도를 하더라도, 배치가 너무 크면 결과 파일 처리/다운로드/파싱 자체가 병목이 됩니다.
2) 업로드/생성 동시성 제한
배치를 여러 개 동시에 만들면 생성/조회 API에서 429가 나기 쉽습니다.
- 업로드 동시성: 1~2
- 배치 생성 동시성: 1~2
- 상태 조회: 점진적 폴링 + 중앙집중(워커들이 각자 폴링하지 않기)
이 패턴은 쿠버네티스/EKS 환경에서 특히 중요합니다. 여러 Pod가 동시에 폴링하면 “내가 만든 트래픽으로 내가 429를 맞는” 상황이 생깁니다. 비슷한 유형의 간헐 장애를 다루는 글로 EKS에서 gRPC 14 UNAVAILABLE 간헐 해결법도 참고할 만합니다(원인은 다르지만 관측/재시도/동시성 제어 관점이 유사).
운영 체크리스트: 재시도보다 먼저 해야 할 것들
1) idempotency(멱등성) 확보
부분 실패 재시도는 결국 “같은 작업을 다시 실행”하는 것입니다. 다음을 지키면 안전해집니다.
custom_id를 작업 단위로 고정- 결과 저장소(DB)에
custom_id유니크 제약을 걸고, 이미 성공한 건 skip - 재시도는
attempt컬럼으로만 증가
DB를 쓴다면 동시성에서 데드락/커넥션 문제도 같이 튀어나올 수 있습니다. 대량 결과 적재 중 too many connections나 데드락이 나면 AI가 아니라 DB가 병목입니다. 필요하면 RDS PostgreSQL too many connections 원인·해결 같은 DB 운영 글도 함께 점검하세요.
2) 실패를 로그가 아닌 ‘데이터’로 남기기
- 배치 ID, input_file_id, output_file_id
- 실패 custom_id 목록
- error.type / error.code / error.message
- 재시도 배치 ID 링크
이게 쌓이면 “어떤 유형이 가장 많이 실패하는지”가 보이고, 그때부터는 재시도가 아니라 입력 품질 개선으로 비용을 줄일 수 있습니다.
3) 타임아웃 기준과 알림
- 배치 생성 후 N분 내
in_progress진입 못하면 경고 in_progress가 M시간 넘으면 알림(큐 지연/정체)completed인데 실패율이 X% 넘으면 알림(입력/스키마 문제)
결론: Batch API 안정화의 핵심은 “부분 실패를 작게 만들고, 작게 다시 시도”
OpenAI Batch API에서 429, 큐 지연, 부분 실패는 피할 수 없는 운영 현실입니다. 중요한 건 이를 하나의 문제로 뭉개지 않고:
- 429는 호출 레벨(업로드/생성/조회)에서 동시성과 백오프로 제어
- 큐 지연은 관측 지표로 만들고, 폴링을 절제
- 부분 실패는 custom_id 기반으로 실패만 재구성해 재시도
이 세 가지를 갖추면 “대량 처리 파이프라인”이 일회성 스크립트가 아니라, 장애를 흡수하는 서비스로 진화합니다.