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/AsyncCursorPaginator가max_retries/backoff_factor/retry_on_status/throttle_secondskeyword 인자를 받습니다. retry 루프가ServerError(5xx)(default502/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_bulkphase-1 채널 (closure 경유). Export handler 가page_retries/page_retry_backoff/throttle_seconds를v2.<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의 defaulttimeout.read가15s → 30s(review F-3 — v2 서브클래스 한정). urllib3Retry.backoff_factor는1 → 2,respect_retry_after_header=True활성화, 그리고Retry-After헤더 값 60s 상한 clamp (review C-1 — :class:BoundedRetry). - 503 surface (sync + async).
Sync:
requests.exceptions.RetryError가ServerError(503, ...)로 명시 매핑 +__cause__보존. Async (review F-1):httpx.NetworkError/httpx.RemoteProtocolError(transient transport hiccup) 도ServerError(503)으로 정규화되어 paginator defaultretry_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.MaxRetryError를ServerError(500, ...)로 변환 (RetryError가 genericRequestException분기에 떨어지기 때문). 결과적으로 backend 의503signal 이 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건).BackendV2Clienttimeout 상향 (15s → 30s) 은 fail 임계를 늘리는 방향입니다. 명시적으로timeout={...}를 전달한 caller 의 값은 그대로 유지됩니다. 기존15sdefault 를 고정하려면SYNAPSE_BACKEND_V2_TIMEOUT_READ=15또는 explicittimeout={'connect': 5, 'read': 15}. v1 caller (plainBaseClient) 는read=15default 그대로 — 본 상향은 v2 한정 scope (review F-3).- 신규 urllib3
backoff_factor=2는 retry cascade 중 sleep 시간만 변경합니다. transport retry 가 발생하지 않는 호출에는 영향 없음. RetryError → ServerError(503)은 회귀가 아니라 refinement 입니다. 이전ServerError(500)매핑은 generic 분기 swallow 의 부작용이었으며, status code500으로 매칭하던 caller 는503(혹은 양쪽 모두 transient 로 처리) 으로 갱신을 권장합니다.
해당 섹션이 포괄하지 않는 행동 변화를 발견하면 후속 ticket 으로 보고해 주세요.
Retry / throttle 체인
v2 export 경로 요청이 통과하는 layered 체인은 다음과 같습니다. 각 layer 마다 자체 opt-in surface 가 있어 caller 가 관심 layer 만 노출하면 됩니다.
두 retry budget 이 layering 됩니다:
- Transport retry — urllib3 /
BaseClient내부에서 connect-level 과 idempotent5xxretry 를backoff_factor=2로 처리. 소진 시ServerError(503)으로 surface. - 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_READ | BackendV2Client / AsyncBackendV2Client 의 요청당 read timeout (초). 공유 BaseClient 는 본 env 채널을 무시 (review F-4). | 30 |
SYNAPSE_BACKEND_V2_TIMEOUT_CONNECT | v2 클라이언트의 요청당 connect timeout (초). 공유 BaseClient 는 무시. | 5 |
SYNAPSE_FORCE_V1_EXPORT | v2 export 마이그레이션 자산. 모든 마이그된 caller 를 v1 경로로 강제. SYN-7062 와 무관하게 그대로 동작. | unset |
두 timeout override 모두 float 로 파싱됩니다. invalid 입력 (non-numeric
또는 ≤ 0) 은 synapse_sdk.clients.utils 가 warning 을 emit 하고
default 로 fallback 합니다. precedence (높은 순):
BaseClient/AsyncBaseClient생성자에 명시한timeout=인자.- 환경 변수.
- 내장 default.
incident 대응 (재배포 없이 read timeout 일시 상향) 이나 로컬 개발 (staging slowness 를 빨리 surface 하려고 read timeout 하향) 등에 환경 채널을 활용하세요.
권장 default
| 워크로드 | page_retries | page_retry_backoff | throttle_seconds | 비고 |
|---|---|---|---|---|
| 소규모 ad-hoc list (< 5 페이지) | 0 | n/a | 0 | opt-out 으로 latency 우선. |
| 운영 export (Task / Assignment / GroundTruth) | 3 | 2.0 | 0.1–0.2 | v1 EXPORT_* default 와 동일. handlers.py 에 이미 wiring 완료. |
| 테넌트 전체 bulk 분석 | 3–5 | 2.0 | 0.2–0.5 | unsupervised 스크립트가 큰 throttle 로 backend 부하 완화. |
| streaming consumer (async) | 3 | 2.0 | 0.0 | backend 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.mdFR-1 ~ FR-6 — paginator retry, resource pass-through,_collect_then_bulk채널, timeout / backoff default, 503 surface, regression guard.specs.mdTS-1 ~ TS-6 — exact signature, env precedence, urllib3 config diff, exception ladder 변경.plans.mdStep 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.