
    ԬjO*                        U d Z ddlZddlZddlmZmZmZmZ ed   Z G d de	      Z
ddded	ed
edeeeef      deeef   f
dZeej                  fZeed<    eej(                  ej*                  ej,                  ej.                  ej0                  h      ZdedefdZ eddh      Z edh      Z eddh      ZeeedZ eh d      Z ed   Z!dedede!fdZ"dedede#fdZ$y)u
  이미지 요청이 **제공자에게 닿았는가** — 세 클라이언트 공용 판정.

## 왜 필요한가

동기 이미지 요청은 서버가 요청을 받은 뒤 **응답만 유실**될 수 있다. 그 상태를
「접수 실패」로 읽고 다시 보내면 **같은 이미지에 요금이 두 번 나가고**, 기록에는
마지막 한 건만 남아 첫 시도의 비용과 결과 신원을 잃는다.

`reve_image_client` 는 2026-08-26 에 이 계약을 먼저 세웠다. 이 모듈은 그것을
**옮겨 온 것이지 새로 만든 것이 아니다** — Grok·Gemini 도 같은 판정을 쓰라고
공용 자리로 올렸다.

## ★이 모듈이 막는 범위 — 넓게 읽으면 안 된다

여기서 막는 것은 **같은 `generate_image()` 호출 안의 자동 재시도**뿐이다.
그 함수가 끝난 뒤 상위가 다시 부르면(resume·JIT 재방문) **또 보낸다** —
같은 논리 호출을 다시 알아보는 durable latch 가 아직 없기 때문이다
(2026-08-26 Codex BLOCK-4). `submission_unknown` 기록은 `llm_call_log`·Opik
에 best-effort 로 남을 뿐 다음 호출을 막지 않는다.

프로세스가 죽은 뒤 resume 이 같은 요청을 다시 보내는 갈래도 마찬가지로
**안 막힌다** — 기동 회수가 죽은 스텝을 `failed` 로 바꾸고 다음 resume 이
바로 가져간다(`step_lock.reclaim_dead_locks_on_startup`).

그것을 닫으려면 결정적 `logical_request_key` + unresolved 조회가 필요하고,
별도 판의 몫이다.

## 가르는 법

**열거로 가르지 않는다.** 「닿지 못한 것이 확정된 것」만 세고 나머지는 전부
`unknown` 이다(fail-closed, 요금 보호).

    never_sent  이름 못 찾음 · 연결 거부 · 「보낼 길이 없다」(EHOSTUNREACH·
                ENETUNREACH·EHOSTDOWN·ENETDOWN) — 커널이 되돌린 것이라
                제공자가 요청을 본 적이 없다
    unknown     그 밖의 모든 실패 — 읽기 시간 초과 · 연결 끊김(ECONNRESET) ·
                파이프 끊김 · 원격 조기 종료 · 대기 시간 마감. 나갔을 수 있다

★대기 시간 마감(`concurrent.futures.TimeoutError`)도 `unknown` 이다. 근거는
「거기까지 갔으니 body 는 나갔을 것」이 **아니다** — `Future.result(timeout)` 은
worker 가 DNS·연결·TLS·업로드·읽기 중 어디에 있는지 알려주지 않고, 도는 스레드는
취소되지도 않는다. **모르니까 보수적으로** `unknown` 인 것이다.

★`socket.timeout` 하나로 연결 단계와 읽기 단계를 가를 수 없다 —
`urlopen(timeout=...)` 은 여러 blocking 연산에 함께 걸린다. 그래서 timeout 은
전부 `unknown` 이다.
    N)AnyDictLiteralOptional)
never_sentunknownc                   6     e Zd ZdZdddededdf fdZ xZS )	ImageSubmissionUnknownui  보냈는지 **모르는** 채로 끝났다 — 다시 보내면 요금이 두 번 나간다.

    호출자가 「일시 장애」와 구분해서 다룰 수 있게 별도 타입이다. 이 예외를
    받은 자리는 **같은 요청을 다시 보내면 안 된다**. 사람이 제공자 대시보드
    에서 요금이 나갔는지 보고 정한다.
     )causemessager   returnNc                2    || _         t        | 	  |       y )N)r   super__init__)selfr   r   	__class__s      V/Users/manta/Documents/Projects/TheRoad-I1/backend/app/modules/llm/image_send_state.pyr   zImageSubmissionUnknown.__init__?   s    
!    )__name__
__module____qualname____doc__strr   __classcell__)r   s   @r   r
   r
   7   s,     68 " "s "D " "r   r
   )baser   
attempt_norequest_shar   r   c                 4    i |xs i dd| t        |      |dS )u  `submission_unknown` 기록에 실을 표시 — 요금이 나갔을 수 있는 시도.

    ★이것은 **durable ledger 가 아니다.** 호출이 끝난 뒤 남기므로 프로세스가
     그 사이에 죽으면 행이 없고, 남아 있어도 **다음 호출을 막지 않는다**.
     막으려면 같은 논리 호출을 다시 알아보는 key 와 unresolved 조회가 있어야
     한다 — 별도 판의 몫이다(2026-08-26 Codex BLOCK-4). 이 한계를 이름으로
     속이지 않는다.
    r   T)
send_statepossible_chargeunknown_causer   effective_request_sha)int)r   r   r   r   s       r   unknown_send_metadatar%   D   s0    :2*o!, r   NEVER_SENT_ERRORSexcc                     | }t        d      D ]Y  }t        |t              r yt        |t              r|j                  t
        v r yt        |dd      }t        |t              s y|}[ y)u  전송 실패를 「안 보내졌다」와 「모른다」로 가른다.

    Returns: ``"never_sent"`` | ``"unknown"``

    ★가르는 기준은 **요청이 서버에 닿았을 가능성**이지 오류의 심각도가
     아니다. 연결 끊김·시간 초과는 가벼워 보여도 「닿았을 수 있다」쪽이다.

    `URLError` 가 원인을 감싸고 있을 수 있어 `.reason` 을 따라 들어간다.
       r   reasonNr   )range
isinstancer&   OSErrorerrno_NEVER_SENT_ERRNOgetattrBaseException)r'   seen_r*   s       r   classify_send_failurer4   p   sh     D1Xd-.dG$7H)Hx.&-0   r   i  i  )gemini
openrouterxai>	                     )	retryablesubmission_unknownterminalcodeviac                    t        |       }|t        j                  |t                     v ry|t        v ryd|cxk  rdk  ry d|cxk  rdk  ry yy)u	  HTTP 응답을 **세 갈래**로 가른다.

    ★bool 로는 안 된다 (2026-08-26 Codex BLOCK-1). 「재시도 불가」 하나에
     400 같은 확정 거부와 500·502·504 같은 **상태를 모르는 응답**이 함께
     담기면, 후자가 일반 오류로 기록돼 `possible_charge` 가 사라진다.

        retryable          다시 보내도 요금이 두 번 안 나간다
        submission_unknown 처리됐을 수 있다 — 재전송 금지, 요금 표시를 남긴다
        terminal           제공자가 거부를 확정했다 — 상태를 안다

    `via` 는 호출 경로다 — `"gemini"`(직접) 또는 `"openrouter"`(경유).
    모르는 경로는 재시도하지 않는다(제공자 계약을 모르면 돈 쪽으로 닫는다).
    rA   rC   i  iX  rB   r@   )r$   	_BY_ROUTEget	frozenset_TERMINAL_STATUS)rD   rE   cs      r   classify_http_statusrL      s\     	D	AIMM#y{++
a~#~#  a~#~ r   c                "    t        | |      dk(  S )u  이 상태에서 **같은 요청을 다시 보내도 요금이 두 번 안 나가는가**.

    ★프로덕션 호출부는 `classify_http_status` 를 직접 쓴다 — 「재시도 불가」
     하나로 뭉치면 「상태를 모르는 응답」이 일반 오류로 묻히기 때문이다
     (Codex BLOCK-1). 이 얇은 감싸개는 **시험이 읽기 쉬우라고** 남긴다.
    )rE   rA   )rL   )rD   rE   s     r   http_status_is_resend_saferN      s      #.+==r   )%r   r.   sockettypingr   r   r   r   	SendStateRuntimeErrorr
   r   r$   r%   ConnectionRefusedErrorgaierrorr&   tuple__annotations__rI   ECONNREFUSEDEHOSTUNREACHENETUNREACH	EHOSTDOWNENETDOWNr/   r1   r4   GEMINI_RESEND_SAFEOPENROUTER_RESEND_SAFEXAI_RESEND_SAFErG   rJ   HttpVerdictrL   boolrN    r   r   <module>rb      sr  .^   / /+,	
"\ 
" &*"14
4S>
" 
#s(^0 
OO 5  				OO	NN  }  N Sz*  #C5) 
 S#J' !(	 JK CD s  C  K  8>S ># >$ >r   