본문으로 건너뛰기

SYN-7062 — v2 Paginator Retry / Throttle Carry-over

본 문서는 v2 export 마이그레이션 의 후속 PR 로, v2 export 경로의 page-level 회복력 갭을 닫습니다. v1 BaseClient._list_all 헬퍼는 페이지 fetch 마다 _get_page_with_retry 루프로 transient 5xx / read timeout 을 흡수했지만, cut-over 시 v2 cursor paginator 의 단일 시도 fetch 로 대체되면서 페이지 N 의 일시적 5xx / read timeout 이 Max retries exceeded 로 전체 export 를 실패시키는 회귀가 발생했습니다. SYN-7062 는 v1 의 page-level retry 계약을 v2 paginator 로 carry-over 하면서 기존 caller 의 호환성을 완전 보장합니다.

본 변경은 additive 입니다 — 명시적 opt-in 없는 caller 는 모두 기존 single-attempt 동작을 그대로 유지합니다. BackendV2Client timeout 과 공유 BaseClient 의 urllib3 backoff default 의 상향만이 v2 path 에 서의 silent 행동 변경입니다. v1 BaseClient 의 timeout default 자체 는 15s 그대로 유지 (review F-3 — 상향은 v2 서브클래스 한정). backoff 변경은 양쪽에 적용되며, 모두 환경 변수로 복원할 수 있습니다.

요약

  • v2 paginator 의 page-level retry. SyncCursorPaginator / AsyncCursorPaginatormax_retries / backoff_factor / retry_on_status / throttle_seconds keyword 인자를 받습니다. retry 루프가 ServerError(5xx) (default 502 / 503 / 504), ClientTimeoutError, ClientConnectionError 를 모두 catch.
  • 23 개 v2 resource 의 4-key pass-through. 모든 v2 resource list()page_retries / page_retry_backoff / throttle_seconds / retry_on_status 가 노출되며 paginator 생성자로 forwarding 됩니다.
  • _collect_then_bulk phase-1 채널 (closure 경유). Export handler 가 page_retries / page_retry_backoff / throttle_secondsv2.<resource>.list(...) closure 로 직접 전달합니다 (_paginator_pass_through() 참조). cursor list 단계가 bulk-fetch 와 동일한 retry / throttle 자세를 inherit. SYN-7062 review C-2 가 helper 본문에서 한 번도 읽지 않던 list_* 4 파라미터 를 제거했습니다 — phase-1 채널은 handler closure 의 책임입니다.
  • v2 한정 transport default 강화. BackendV2Client / AsyncBackendV2Client 의 default timeout.read15s → 30s (review F-3 — v2 서브클래스 한정). urllib3 Retry.backoff_factor1 → 2, respect_retry_after_header=True 활성화, 그리고 Retry-After 헤더 값 60s 상한 clamp (review C-1 — :class:BoundedRetry).
  • 503 surface (sync + async). Sync: requests.exceptions.RetryErrorServerError(503, ...) 로 명시 매핑 + __cause__ 보존. Async (review F-1): httpx.NetworkError / httpx.RemoteProtocolError (transient transport hiccup) 도 ServerError(503) 으로 정규화되어 paginator default retry_on_status=(502, 503, 504) 에 걸립니다.
  • 환경 변수 override. SYNAPSE_BACKEND_V2_TIMEOUT_READ / _CONNECT 로 v2 클라이언트의 per-process timeout 조정 가능 (review F-4 — v2 한정 scope). 잘못된 값은 warning 후 default 로 fallback. 공유 BaseClient / AsyncBaseClient 는 본 env 채널을 무시하여 v1 caller 의 SLA 를 보존합니다.

배경 — 회귀 경위

v1 export 는 BaseClient._list_all_get_page_with_retry 가 페이지별 transient 실패를 흡수했습니다. SYN-6919 cut-over PR 은 export handler 를 v2 client 의 cursor paginator 로 이전했고, 이는 페이지 fetch 를 정확히 1 회만 수행했습니다. Staging 에서 다음 양상으로 드러났습니다:

  • 페이지 0 은 성공, 페이지 N (N ≥ 1) 이 503 또는 read timeout 응답 → paginator 즉시 raise.
  • BackendV2Client._request 가 내부 urllib3.MaxRetryErrorServerError(500, ...) 로 변환 (RetryError 가 generic RequestException 분기에 떨어지기 때문). 결과적으로 backend 의 503 signal 이 handler 까지 도달 못 함.
  • Handler 는 이를 hard failure 로 취급 → export 전체 abort. 부분 진행 없음, recovery 없음.

운영 회피 채널 (SYNAPSE_FORCE_V1_EXPORT=1) 은 여전히 유효하지만, 모든 마이그된 caller 를 v1 으로 강제 회귀시켜 cut-over 의 의미를 무력화합니다. SYN-7062 는 v2 경로의 page-level retry 계약을 복원하여 kill-switch 가 기본 unset 으로 유지되도록 합니다.

호환성 보장

기존 caller 는 코드 한 줄도 바꿀 필요 없이 모두 동일한 동작을 유지합니다:

  • 신규 paginator 생성자 keyword 의 default 가 max_retries=0, throttle_seconds=0.0 입니다. opt-in 없는 호출은 변경 전과 동일.
  • 신규 v2 resource list() keyword 의 default 는 disabled / zero. client.v2_client.tasks.list(project=42) 는 기존 CursorPage 와 동일 반환.
  • _collect_then_bulk 는 기존 caller 의 핵심 파라미터 계약 (list_method / bulk_method / ids_per_batch / extract_id / throttle_seconds) 을 그대로 유지합니다. Step 3 에서 잠시 도입했던 4 개의 list_* 파라미터는 review C-2 에서 제거됐습니다 — 본문이 한 번도 읽지 않던 dead code 였고 phase-1 채널은 handler closure 의 책임입니다. 방어적으로 list_* 키를 전달하던 caller 가 있었다면 silent no-op 대신 TypeError 가 발생 (SDK 내부 caller 0건).
  • BackendV2Client timeout 상향 (15s → 30s) 은 fail 임계를 늘리는 방향입니다. 명시적으로 timeout={...} 를 전달한 caller 의 값은 그대로 유지됩니다. 기존 15s default 를 고정하려면 SYNAPSE_BACKEND_V2_TIMEOUT_READ=15 또는 explicit timeout={'connect': 5, 'read': 15}. v1 caller (plain BaseClient) 는 read=15 default 그대로 — 본 상향은 v2 한정 scope (review F-3).
  • 신규 urllib3 backoff_factor=2 는 retry cascade 중 sleep 시간만 변경합니다. transport retry 가 발생하지 않는 호출에는 영향 없음.
  • RetryError → ServerError(503) 은 회귀가 아니라 refinement 입니다. 이전 ServerError(500) 매핑은 generic 분기 swallow 의 부작용이었으며, status code 500 으로 매칭하던 caller 는 503 (혹은 양쪽 모두 transient 로 처리) 으로 갱신을 권장합니다.

해당 섹션이 포괄하지 않는 행동 변화를 발견하면 후속 ticket 으로 보고해 주세요.

Retry / throttle 체인

v2 export 경로 요청이 통과하는 layered 체인은 다음과 같습니다. 각 layer 마다 자체 opt-in surface 가 있어 caller 가 관심 layer 만 노출하면 됩니다.

두 retry budget 이 layering 됩니다:

  1. Transport retry — urllib3 / BaseClient 내부에서 connect-level 과 idempotent 5xx retry 를 backoff_factor=2 로 처리. 소진 시 ServerError(503) 으로 surface.
  2. Page-level retry_fetch_with_retry 내부에서 위 catch 목록을 처리. 소진 시 마지막 exception 을 그대로 re-raise.

두 budget 은 독립적입니다. 한 페이지가 transport budget 을 모두 소진해 ServerError(503) 으로 surface 되어도 page-level 루프가 다시 retry 할 수 있습니다. 이 layering 이 v1 _get_page_with_retry 의 의미론을 v2 경로에서 재현합니다.

Page-level retry 시간선

page-level 루프는 시도 사이에 backoff_factor * (2 ** attempt) 초 sleep 합니다. 권장 default page_retries=3 / page_retry_backoff=2.0 기준 페이지당 worst-case 추가 벽시계 시간은 2 + 4 + 8 = 14 초.

페이지 N 이 4 회 (초기 + 3 retry) 를 모두 소진해도 실패하면 루프는 마지막 ServerError(503) 을 그대로 re-raise 합니다. handler 가 Max retries exceeded 대신 의미있는 실패 코드를 surface 할 수 있습니다.

새 옵션 활용법

Paginator 직접 사용

분석 스크립트나 custom export 파이프라인이 paginator 를 직접 구성하는 경우:

from synapse_sdk.clients.backend_v2 import BackendV2Client

client = BackendV2Client('https://api.test.synapse.sh',
access_token='syn_...', tenant='acme')

# page 0 inline, 후속 페이지도 동일 retry 정책 공유.
for row in client.v2.tasks.list(
project=42,
list_all=True,
page_retries=3,
page_retry_backoff=2.0,
throttle_seconds=0.2,
):
process(row)

4 개 keyword 인자가 paginator 생성자로 forwarding 됩니다. retry_on_status default 는 (502, 503, 504) 이며, 429 를 추가하거나 502 를 빼는 식의 조정이 가능합니다.

_collect_then_bulk (export handler 패턴)

Export handler 는 계속 _collect_then_bulk 를 사용합니다. helper 에 phase-1 채널이 노출되어 cursor list 단계가 bulk-fetch sequence 와 동일 retry / throttle 자세를 inherit 합니다:

from synapse_sdk.plugins.actions._v2_switch import _collect_then_bulk

# closure 가 paginator pass-through 3 key 를 resource list 단계로 전달.
# 핸들러의 _paginator_pass_through() 가 동일 dict 의 SSOT 이다.
list_paginator_kwargs = self._paginator_pass_through()
# 결과:
# {
# 'page_retries': self.EXPORT_PAGE_RETRIES, # default 3
# 'page_retry_backoff': self.EXPORT_PAGE_RETRY_BACKOFF, # default 2.0
# 'throttle_seconds': self.EXPORT_THROTTLE_SECONDS, # 0.1 / 0.2
# }

rows = _collect_then_bulk(
list_method=lambda: v2.tasks.list(
list_all=True, **slim_params, **list_paginator_kwargs,
),
bulk_method=lambda ids: v2.tasks.bulk_fetch(ids),
ids_per_batch=self.EXPORT_PAGE_SIZE,
# phase 2 (inter-bulk-fetch) throttle. phase 1 throttle 은 위
# closure 가 ``v2.tasks.list`` 로 직접 전달.
throttle_seconds=self.EXPORT_THROTTLE_SECONDS,
)

플러그인 작성자가 _collect_then_bulk callsite 를 직접 구성하는 경우 동일 closure 패턴을 권장합니다. caller 가 slim_params 에 실수로 paginator 예약 키 (page_retries / page_retry_backoff / throttle_seconds / retry_on_status) 를 넣으면 double-spread TypeError 가 발생하므로, SDK 는 5 개 v2-migrated callsite (export Task / Assignment / GroundTruthEvent handler, dataset/action.py, to_task/steps/fetch_tasks.py) 모두에 synapse_sdk.plugins.actions._v2_switch.strip_paginator_reserved_keys public helper 로 sanitize 를 적용합니다 (review C-3, NEW-1). drop 시점에 WARNING log 가 함께 출력되어 caller 오용을 즉시 관찰할 수 있습니다.

_collect_then_bulk 에 한때 존재했던 4 개의 list_* 파라미터 (list_retries / list_retry_backoff / list_throttle_seconds / list_retry_on_status) 는 review C-2 에서 제거됐습니다 — helper 본문이 한 번도 읽지 않던 dead code 였으며 phase 1 채널은 handler closure 의 책임입니다.

Async paginator

async paginator 도 동일 옵션을 받습니다. sync 예제와 동일한 keyword 이름을 client.v2.tasks.list(...) 로 전달하면 됩니다.

async with AsyncBackendV2Client(...) as client:
async for row in client.v2.tasks.list(
project=42,
list_all=True,
page_retries=3,
throttle_seconds=0.2,
):
await sink(row)

async 루프는 asyncio.sleep 으로 backoff 를 처리하여 surrounding event loop 와 협력합니다 (block 하지 않음).

환경 변수 override

변수효과Default
SYNAPSE_BACKEND_V2_TIMEOUT_READBackendV2Client / AsyncBackendV2Client 의 요청당 read timeout (초). 공유 BaseClient 는 본 env 채널을 무시 (review F-4).30
SYNAPSE_BACKEND_V2_TIMEOUT_CONNECTv2 클라이언트의 요청당 connect timeout (초). 공유 BaseClient 는 무시.5
SYNAPSE_FORCE_V1_EXPORTv2 export 마이그레이션 자산. 모든 마이그된 caller 를 v1 경로로 강제. SYN-7062 와 무관하게 그대로 동작.unset

두 timeout override 모두 float 로 파싱됩니다. invalid 입력 (non-numeric 또는 ≤ 0) 은 synapse_sdk.clients.utils 가 warning 을 emit 하고 default 로 fallback 합니다. precedence (높은 순):

  1. BaseClient / AsyncBaseClient 생성자에 명시한 timeout= 인자.
  2. 환경 변수.
  3. 내장 default.

incident 대응 (재배포 없이 read timeout 일시 상향) 이나 로컬 개발 (staging slowness 를 빨리 surface 하려고 read timeout 하향) 등에 환경 채널을 활용하세요.

권장 default

워크로드page_retriespage_retry_backoffthrottle_seconds비고
소규모 ad-hoc list (< 5 페이지)0n/a0opt-out 으로 latency 우선.
운영 export (Task / Assignment / GroundTruth)32.00.10.2v1 EXPORT_* default 와 동일. handlers.py 에 이미 wiring 완료.
테넌트 전체 bulk 분석352.00.20.5unsupervised 스크립트가 큰 throttle 로 backend 부하 완화.
streaming consumer (async)32.00.0backend 503 폭주 관측 시에만 throttle. backoff 만으로 transient 흡수 충분.

위 값은 시작점일 뿐 절대값이 아닙니다. 운영 incident 시 backend Retry-After 헤더를 함께 관찰하세요 — urllib3 가 이 헤더를 존중하므로 실제 wall time 이 backoff_factor 곡선보다 커질 수 있습니다.

Troubleshooting

ServerError(503, ...) 가 caller 까지 그대로 raise 됨

urllib3 가 단일 시도 안에서 transport retry budget 을 소진한 상태 입니다. __cause__ 체인을 확인하세요 — 원본 RetryError 와 underlying MaxRetryError 가 보존되어 있어 마지막 응답이나 연결 실패 원인을 읽을 수 있습니다. 직접 503 surface 가 발생하는 흔한 원인:

  • backend 가 실제로 과부하 상태 (운영 대시보드 확인).
  • endpoint 가 client-side budget 을 초과하는 Retry-After 헤더 반환.
  • 네트워크 단절로 모든 transport retry 가 실패.

page-level 루프가 흡수하기를 원한다면 page_retries (paginator 직접 구성 시 max_retries) 를 > 0 으로 설정하세요.

Page-level retry 루프가 첫 시도에서 종료됨

retry_on_status 가 backend 응답 코드를 포함하는지 확인하세요. default 는 (502, 503, 504) 이며, 그 밖의 코드 (예: 429) 는 즉시 raise 됩니다. 배포 환경이 429 를 soft rate-limit 으로 사용한다면 더 넓은 tuple 을 전달하세요.

Async retry 가 deadlock 처럼 보임

async paginator 는 asyncio.sleep 으로 backoff 를 처리합니다. surrounding event loop 가 starve 상태 (긴 동기 CPU 작업, run_until_complete 를 동기 호출이 blocking) 이면 sleep 이 hang 처럼 보입니다. 호출 task 의 동기 병목을 점검하세요.

timeout default 변경으로 테스트 fixture 가 깨짐

sync default 가 15s → 30s 로 이동했습니다. test 환경에서 SYNAPSE_BACKEND_V2_TIMEOUT_READ=15 를 설정하거나 client 생성 시 timeout={'connect': 5, 'read': 15} 를 명시 전달하여 이전 값을 고정하세요.

계약 참조

다음 spec 라인이 위 동작의 source of truth 입니다:

  • requirements.md FR-1 ~ FR-6 — paginator retry, resource pass-through, _collect_then_bulk 채널, timeout / backoff default, 503 surface, regression guard.
  • specs.md TS-1 ~ TS-6 — exact signature, env precedence, urllib3 config diff, exception ladder 변경.
  • plans.md Step 1 ~ Step 7 — atomic commit topology (구조 → 행동 → docs).

(저장 경로: specs/syn-7062-v2-paginator-retry-carryover/)

관련 문서

  • v2 export 마이그레이션 — base cut-over PR. SYN-7062 는 그 page-level 회복력 갭을 닫는 follow-up.
  • synapse_sdk/clients/backend_v2/INVENTORY.md — 4-key pass-through 가 적용된 23 개 resource 의 endpoint 카탈로그.
  • synapse_sdk/clients/backend_v2/README.md — paginator 직접 구성 quick-start.