이벤트와 SSE
Turn과 Run의 진행은 이벤트로도 받을 수 있습니다. 폴링, SSE, 웹훅은 모두 같은 공개 기록을 읽으므로 방식에 따라 결과가 달라지지 않습니다.
폴링과 SSE
| 방식 | 언제 쓰나 |
|---|---|
| 폴링 | 가장 단순합니다. 상태 조회를 2~5초 간격으로 반복합니다. 종결 상태에서 멈추세요. |
| SSE | 진행 이벤트를 실시간으로 보고 싶을 때. 연결이 끊기면 마지막 이벤트부터 이어 받습니다. |
| 웹훅 | 서버가 완료를 알려 주길 원할 때. 웹훅을 보세요. |
SSE는 GET /v1/sessions/{session_id}/events와 GET /v1/runs/{run_id}/events입니다. events:read scope가 필요하고, Run은 runs:read도 필요합니다. 연결이 끊기거나 서버가 닫아도 이미 접수한 실행은 취소되지 않습니다.
curl -N "$BASE/v1/sessions/$SESSION_ID/events" \
-H "Authorization: Bearer $UPSERVE_API_KEY" \
-H "Accept: text/event-stream"
# Reconnect and continue after the last event you processed
curl -N "$BASE/v1/sessions/$SESSION_ID/events" \
-H "Authorization: Bearer $UPSERVE_API_KEY" \
-H "Last-Event-ID: $LAST_EVENT_ID"프레임 형식
각 이벤트는 SSE 표준 프레임 하나입니다.
id는 이벤트의event_id입니다. 불투명한 UUID이며 순서를 추론하지 마세요.event는 이벤트 종류입니다. 예를 들어message.created,tool.started,turn.progress,turn.completed,run.step.completed,run.completed가 있습니다.data는 JSON 한 줄입니다.event_id,aggregate_kind,aggregate_id,sequence,type,created_at,data를 담습니다. 안의data.kind가 내용의 종류를 알려 줍니다.: keep-alive로 시작하는 줄은 약 15초마다 오는 주석입니다. 무시하세요.sequence는 같은 Session이나 같은 Run 안에서만 비교할 수 있습니다. Session과 Run의 번호를 섞어 비교하지 마세요.
id: 7c2e0d44-0000-4000-8000-000000000007
event: message.created
data: {"event_id":"7c2e0d44-0000-4000-8000-000000000007","aggregate_kind":"SESSION","aggregate_id":"6f1c0a52-0000-4000-8000-000000000001","sequence":3,"type":"message.created","created_at":"2026-10-05T09:00:18+00:00","data":{"kind":"MESSAGE"}}
: keep-alive
id: 7c2e0d44-0000-4000-8000-000000000008
event: turn.completed
data: {"event_id":"7c2e0d44-0000-4000-8000-000000000008","aggregate_kind":"SESSION","aggregate_id":"6f1c0a52-0000-4000-8000-000000000001","sequence":4,"type":"turn.completed","created_at":"2026-10-05T09:00:20+00:00","data":{"kind":"OUTCOME","status":"SUCCEEDED"}}공개 이벤트에는 도구 이름, 호출 ID, 진행 단계, 정화된 최종 메시지만 담깁니다. 도구 인자와 결과 원문, 내부 추론은 포함되지 않습니다.
아래는 SSE 프레임을 읽는 최소 예시입니다.
def events(session_id: str, last_event_id: str | None = None):
headers = {**AUTH, "Accept": "text/event-stream"}
if last_event_id:
headers["Last-Event-ID"] = last_event_id
with httpx.stream("GET", f"{BASE}/v1/sessions/{session_id}/events", headers=headers, timeout=None) as response:
response.raise_for_status()
frame = {}
for line in response.iter_lines():
if line == "":
if frame:
yield frame
frame = {}
elif not line.startswith(":"):
name, _, value = line.partition(":")
frame[name] = value.removeprefix(" ")Last-Event-ID로 재연결
- 연결이 끊기면 마지막으로 처리를 끝낸 이벤트의
id를Last-Event-ID헤더(또는after쿼리)에 넣어 다시 연결합니다. 서버는 그 다음 이벤트부터 다시 보냅니다. - 둘을 함께 보내면 같은 ID여야 합니다. 다르면
400 INVALID_CURSOR입니다. - 다른 Session이나 Run의 ID를 넣으면
404입니다. - 같은 이벤트가 두 번 올 수 있으니
event_id로 중복을 거르세요. - 재연결이 계속 실패하면 간격을 늘려 가며 시도하고, 그 사이에는 폴링으로 상태를 확인하세요.
만료된 cursor 복구 (410)
이벤트와 Item 내용은 최대 30일 보관하며, 오래된 cursor는 410 EVENT_CURSOR_EXPIRED가 됩니다. 응답의 details에 복구 경로가 있습니다.
{
"error": {
"code": "EVENT_CURSOR_EXPIRED",
"message": "The event cursor has expired",
"retryable": false,
"request_id": "0b7b8f0e-0000-4000-8000-000000000009",
"details": {
"state_url": "/v1/sessions/6f1c0a52-0000-4000-8000-000000000001/turns/6f1c0a52-0000-4000-8000-000000000002",
"items_url": "/v1/sessions/6f1c0a52-0000-4000-8000-000000000001/items"
}
}
}state_url을 조회해 현재 상태를 읽습니다.items_url을 조회해 보존된 결과를 읽습니다.- 이어서 이벤트가 필요하면 cursor 없이 SSE를 다시 엽니다.
Session 내용 자체의 보존이 끝났다면 같은 코드에 details.content_expired가 true로 옵니다. 이 경우 복구 주소가 있어도 보존 기간이 늘어나지는 않습니다.
헤더를 보낸 뒤의 오류
SSE는 200과 헤더를 먼저 보내므로, 그 뒤에 생긴 오류는 HTTP 상태가 아니라 event: error 프레임으로 옵니다. 이 프레임에는 id가 없고, 보낸 뒤 서버가 연결을 닫습니다.
event: error
data: {"error":{"code":"EVENT_CURSOR_EXPIRED","message":"...","retryable":false,"request_id":"...","details":{}}}- 오류의
retryable이true이면 마지막id로 다시 연결합니다. - 연결 중에도 키, Deployment, Session 권한을 주기적으로 다시 확인합니다. 키가 폐기되거나 권한이 회수되면 연결이 닫힙니다.
느린 소비자
이벤트를 너무 느리게 읽어 서버 전송 한도(약 10초)를 넘기면 서버는 연결을 끝냅니다. 서버는 무제한으로 쌓아 두지 않습니다. 마지막으로 처리한 id로 재연결하면 빠진 이벤트를 이어 받습니다. 이벤트 처리가 느리다면 받은 프레임을 큐에 넣고 별도 작업으로 처리하세요.