项目文件夹

文件
wehub-resource-sync 09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

383 行
18 KiB
Python

"""Authed POST transport collaborator — the middleware-chain leaf.
``RuntimeTransport`` owns the three pieces of the authed POST hot path
(history: docs/refactor-history.md):
* :meth:`RuntimeTransport.terminal` — the middleware-chain leaf. Sends
the populated :class:`RpcRequest` via :meth:`Kernel.post` and maps the
raw transport errors into the ``Transport*`` exception shapes consumed
by ``RetryMiddleware`` / ``AuthRefreshMiddleware``.
* :meth:`RuntimeTransport.refresh_request_for_current_auth` — re-builds
the envelope from ``RPC_CONTEXT_BUILD_REQUEST`` if a concurrent refresh
moved the auth snapshot between materialization and the terminal POST.
* :meth:`RuntimeTransport.perform_authed_post` — the entry point the
RPC executor / chat path call. Runs the
loop-affinity guard, captures the current auth snapshot, materializes
the request envelope, dispatches it through the wired middleware
chain, and records the semaphore queue-wait latency.
:class:`MiddlewareChainHost` owns the chain leaf
(:meth:`MiddlewareChainHost._authed_post_chain_terminal`), the chain
slot (``chain_host._authed_post_chain``), and the three retry-budget
tunables (``_rate_limit_max_retries`` / ``_server_error_max_retries`` /
``_refresh_retry_delay``). ``perform_authed_post`` does not read the
retry-delay directly — the retry/backoff budget for the refresh path
is owned by ``AuthRefreshMiddleware`` and by
``RpcExecutor.try_refresh_and_retry``, both of which read
``chain_host._refresh_retry_delay`` live through provider lambdas wired
in ``_runtime.init.wire_middleware_chain``. Integration tests that
assign ``client._composed.chain_host._refresh_retry_delay = 0`` keep
steering the live delay.
Construction order in :func:`compose_client_internals`:
:func:`notebooklm._runtime.init.build_runtime_transport` constructs the
transport **before** :func:`wire_middleware_chain`. The wired chain
leaf is :meth:`MiddlewareChainHost._authed_post_chain_terminal` (a
one-line forward to :meth:`RuntimeTransport.terminal`) — wiring through
the host preserves the canonical fixture-rebind seam (tests that
swap the chain leaf or the chain itself rebind on the host directly).
The chain itself is reached by the transport through an injected
``chain_provider`` closure that reads
``chain_host._authed_post_chain`` live, late on every
:meth:`perform_authed_post` call; this both breaks the construction
cycle and preserves the long-standing test pattern of reassigning
``core._composed.chain_host._authed_post_chain`` to install a fake chain. The
:class:`AuthRefreshCoordinator` snapshot is reached via an injected
``snapshot_provider`` callable so :class:`RuntimeTransport` never has
to hold a direct back-reference to the composition root.
"""
from __future__ import annotations
import logging
import time
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
import httpx
from .._middleware.context import (
RPC_CONTEXT_AUTH_SNAPSHOT,
RPC_CONTEXT_BUILD_REQUEST,
RPC_CONTEXT_DISABLE_INTERNAL_RETRIES,
RPC_CONTEXT_DISABLE_READ_TIMEOUT_RETRIES,
RPC_CONTEXT_LOG_LABEL,
RPC_CONTEXT_MAX_RESPONSE_BYTES,
RPC_CONTEXT_READ_TIMEOUT,
RPC_CONTEXT_REFRESH_BUDGET,
RPC_CONTEXT_RETRY_DEADLINE,
RPC_CONTEXT_RPC_METHOD,
RPC_CONTEXT_RPC_QUEUE_WAIT_SECONDS,
)
from .._middleware.core import (
NextCall,
RpcRequest,
RpcResponse,
materialize_rpc_request,
)
from .._request_types import AuthSnapshot, BuildRequest
from .._transport_errors import raise_mapped_post_error
if TYPE_CHECKING:
from .._auth_refresh_retry import RefreshBudget
from .._client_metrics import ClientMetrics
from .._deadline import RuntimeDeadline
from .._kernel import Kernel
class RuntimeTransport:
"""Authed POST chain leaf and entry-point collaborator.
Owns the three authed-POST hot-path methods.
Does NOT own lifecycle (that stays on :class:`ClientLifecycle`) nor
retry/refresh budget state (that lives on
:class:`MiddlewareChainHost` and is threaded into middleware via
provider lambdas).
The chain reference is fetched late on every
:meth:`perform_authed_post` — just before chain dispatch, after
snapshot + materialization — through the injected ``chain_provider``
closure (typically ``lambda: chain_host._authed_post_chain``). The
lookup is intentionally deferred so a chain reassignment that
happens while the snapshot capture awaits still steers the
dispatch; the chain is read at the dispatch site.
The injected ``logger`` is held so error messages mapped through
:func:`notebooklm._transport_errors.raise_mapped_post_error` keep
appearing under the historical session logger namespace rather than
this module's namespace — preserving the log-filter / caplog
vocabulary callers may already rely on.
"""
def __init__(
self,
*,
kernel: Kernel,
snapshot_provider: Callable[[], Awaitable[AuthSnapshot]],
chain_provider: Callable[[], NextCall | None],
metrics: ClientMetrics,
bound_loop_check: Callable[[], None],
logger: logging.Logger,
) -> None:
self._kernel = kernel
self._snapshot_provider = snapshot_provider
# Live-binding chain accessor. The wired chain is installed onto
# :class:`MiddlewareChainHost` AFTER :class:`RuntimeTransport`
# is constructed (the chain's leaf is :meth:`terminal`, so the
# transport must exist first). Tests also reassign
# ``core._composed.chain_host._authed_post_chain`` post-construction to
# install a fake chain — going through a provider closure
# (called late in :meth:`perform_authed_post`) ensures those
# reassignments take effect on the next call without any
# further mutation here.
self._chain_provider = chain_provider
self._metrics = metrics
self._bound_loop_check = bound_loop_check
self._logger = logger
async def refresh_request_for_current_auth(self, request: RpcRequest) -> RpcRequest:
"""Rebuild the envelope from the current auth snapshot before every POST.
This guard is **load-bearing**: it runs on *every* terminal attempt
(including retries driven by ``RetryMiddleware`` for 429 / 5xx) and
unconditionally rebuilds ``RpcRequest.url`` / ``.headers`` / ``.body``
from a freshly captured :class:`AuthSnapshot` whenever
``RPC_CONTEXT_BUILD_REQUEST`` is present. The unconditional rebuild
is the runtime correctness fix for the stale-envelope path that
existed when the freshness check short-circuited on snapshot
equality:
1. Initial attempt: snapshot ``S_old`` is captured by
:meth:`perform_authed_post`, the envelope is materialized, and
the request enters the chain.
2. Terminal POSTs and the response is HTTP 401.
3. :class:`AuthRefreshMiddleware` (just inside ``RetryMiddleware``)
catches the auth error, refreshes credentials, mutates
``request.context[RPC_CONTEXT_AUTH_SNAPSHOT]`` to ``S_new``
in-place (see
:meth:`AuthRefreshMiddleware._rebuild_request_after_refresh`
for the contract — that mutation is the carrier of the new
snapshot across the ``Retry`` ↔ ``AuthRefresh`` boundary), and
hands a freshly built ``retry_request`` to the chain leaf.
4. The retry attempt POSTs with the refreshed envelope and the
response is HTTP 429.
5. The 429 propagates back up to ``RetryMiddleware`` (outside
``AuthRefreshMiddleware``), which retries by re-invoking the
chain with the **original** ``RpcRequest`` from step 1. That
request's ``.url`` / ``.headers`` / ``.body`` were built from
``S_old`` even though its shared ``context`` dict now carries
``S_new`` (mutated in step 3).
6. Without an unconditional rebuild here, a snapshot-equality
short-circuit would compare ``S_new`` (in context) against
``S_new`` (freshly captured), declare "no change," and send the
stale ``S_old`` envelope. The unconditional rebuild keeps
``URL`` / ``headers`` / ``body`` aligned with
:attr:`Kernel._client.cookies` (which carries the refreshed
cookie jar) for every attempt.
Idempotence on the happy path: when no refresh ran, the snapshot
captured here equals the snapshot used by
:meth:`perform_authed_post`, so the rebuilt envelope is
byte-identical to the inbound one. The extra ``build_request``
invocation per attempt is the cost of the freshness invariant.
AST guarded — see
:func:`tests.unit.test_concurrency_refresh_race.test_terminal_freshness_check_has_no_await_after_materialization`
which reads the source of this method to assert no ``await``
follows :func:`materialize_rpc_request`. Any restructuring here
must keep that invariant: the snapshot and the rebuilt envelope
must be produced together with no suspension point between them
so a concurrent refresh cannot move the cookie jar between the
rebuild and :meth:`Kernel.post`.
"""
context = request.context
build_request = context.get(RPC_CONTEXT_BUILD_REQUEST)
if build_request is None:
return request
current_snapshot = await self._snapshot_provider()
context[RPC_CONTEXT_AUTH_SNAPSHOT] = current_snapshot
return materialize_rpc_request(
build_request=build_request,
snapshot=current_snapshot,
context=context,
)
async def terminal(self, request: RpcRequest) -> RpcResponse:
"""Chain leaf — sends the populated ``RpcRequest`` via ``Kernel.post``.
The chain interface carries the actual HTTP request. The terminal
reads ``RpcRequest.url`` / ``headers`` / ``body`` directly, maps raw
``Kernel.post`` errors into the transport exception shapes consumed
by ``RetryMiddleware`` / ``AuthRefreshMiddleware``, and wraps the
returned :class:`httpx.Response` in :class:`RpcResponse`.
AST guarded — see
:func:`tests.unit.test_concurrency_refresh_race.test_kernel_post_terminal_has_no_await_before_post_per_attempt`
which reads the source of this method to assert no ``await``
precedes the ``self._kernel.post(...)`` call inside the protective
``try`` block. A concurrent refresh between freshness rebuild and
the POST would otherwise mismatch the cookie jar against the
materialized headers.
"""
request = await self.refresh_request_for_current_auth(request)
context = request.context
log_label = context.get(RPC_CONTEXT_LOG_LABEL, "<unknown-chain-call>")
read_timeout = context.get(RPC_CONTEXT_READ_TIMEOUT)
post_kwargs: dict[str, Any] = {}
if RPC_CONTEXT_MAX_RESPONSE_BYTES in context:
post_kwargs["max_response_bytes"] = context[RPC_CONTEXT_MAX_RESPONSE_BYTES]
start = time.perf_counter()
try:
response = await self._kernel.post(
request.url,
headers=request.headers,
body=request.body,
read_timeout=read_timeout,
**post_kwargs,
)
except (httpx.HTTPStatusError, httpx.RequestError) as exc:
raise_mapped_post_error(
log_label=log_label,
exc=exc,
start=start,
logger=self._logger,
)
return RpcResponse(response=response, context=context)
async def perform_authed_post(
self,
*,
build_request: BuildRequest,
log_label: str,
disable_internal_retries: bool = False,
rpc_method: str | None = None,
refresh_budget: RefreshBudget | None = None,
retry_deadline: RuntimeDeadline | None = None,
read_timeout: float | None = None,
max_response_bytes: int | None = None,
disable_read_timeout_retries: bool = False,
) -> httpx.Response:
"""Authed POST entry point — routes through the middleware chain.
Shared transport surface used by ``RpcExecutor._execute_once``
(``_rpc_executor.py``) and ``_chat.transport``
(``_chat/transport.py``); keep the same keyword-only signature.
``RpcRequest.url`` / ``headers`` / ``body`` are populated through
:func:`materialize_rpc_request` before the chain sees the
request. ``RPC_CONTEXT_BUILD_REQUEST`` remains as the bounded
rebuild recipe for auth-refresh and pre-terminal freshness
checks.
``refresh_budget`` is an optional
:class:`notebooklm._auth_refresh_retry.RefreshBudget` seeded by the
RPC executor so the HTTP-status refresh layer
(:class:`AuthRefreshMiddleware`) shares its once-per-logical-call
refresh allowance with the executor's decoded-RPC refresh layer
(issue #1205). Callers that drive the chain without a budget (the
chat path) pass ``None``; the middleware then falls back to its
per-chain ``RPC_CONTEXT_AUTH_REFRESHED`` boolean.
``retry_deadline`` is an optional
:class:`notebooklm._deadline.RuntimeDeadline` seeded by the RPC
executor so the chain's :class:`RetryMiddleware` INHERITS the logical
call's aggregate retry deadline (anchored at T0) instead of minting a
fresh one at chain re-entry. This keeps the 429/5xx retry budget from
restarting across a decode-time auth-refresh retry (issue #1873).
Callers that drive the chain without an aggregate deadline (the chat
path) pass ``None``; ``RetryMiddleware`` then falls back to
``_start_retry_deadline()``.
Raises:
RuntimeError: if the chain provider returns ``None``. The
wired chain is installed by the composition root in
:func:`notebooklm._runtime.init.wire_middleware_chain`
(driven from ``NotebookLMClient.__init__``) immediately
after :class:`RuntimeTransport` is built; a ``None`` value
indicates a construction-time wiring bug, not a runtime
condition.
"""
# Event-loop affinity guard. The check lives here so it fires once
# per chain invocation rather than once per leaf attempt.
# ``assert_bound_loop`` (forwarded through ``bound_loop_check``) is
# a no-op when ``bound_loop`` is ``None`` (pre-open / fresh
# fixture); it raises only when the currently-running loop differs
# from the one captured at ``open()``-time.
self._bound_loop_check()
context: dict[str, Any] = {
RPC_CONTEXT_BUILD_REQUEST: build_request,
RPC_CONTEXT_LOG_LABEL: log_label,
RPC_CONTEXT_DISABLE_INTERNAL_RETRIES: disable_internal_retries,
RPC_CONTEXT_RPC_METHOD: rpc_method,
}
if read_timeout is not None:
context[RPC_CONTEXT_READ_TIMEOUT] = read_timeout
if max_response_bytes is not None:
context[RPC_CONTEXT_MAX_RESPONSE_BYTES] = max_response_bytes
if disable_read_timeout_retries:
context[RPC_CONTEXT_DISABLE_READ_TIMEOUT_RETRIES] = True
# Only seed the shared refresh budget when one is supplied. Callers
# that drive the chain without a budget (the chat path) leave the key
# ABSENT, matching the ``RPC_CONTEXT_REFRESH_BUDGET`` docstring; the
# auth-refresh middleware then falls back to its per-chain
# ``RPC_CONTEXT_AUTH_REFRESHED`` boolean.
if refresh_budget is not None:
context[RPC_CONTEXT_REFRESH_BUDGET] = refresh_budget
# Only seed the aggregate retry deadline when one is supplied. Callers
# that drive the chain without an aggregate deadline (the chat path)
# leave the key ABSENT; ``RetryMiddleware`` then mints its own via
# ``_start_retry_deadline()`` (issue #1873).
if retry_deadline is not None:
context[RPC_CONTEXT_RETRY_DEADLINE] = retry_deadline
snapshot = await self._snapshot_provider()
request = materialize_rpc_request(
build_request=build_request,
snapshot=snapshot,
context=context,
)
context[RPC_CONTEXT_AUTH_SNAPSHOT] = snapshot
# The ``max_concurrent_rpcs`` slot is acquired by
# :class:`SemaphoreMiddleware` (chain position 2, between Metrics
# and Retry) — that placement keeps Drain admitting queued tasks
# AND keeps Metrics timing the queue wait, while still bounding
# the retry-and-refresh cohort to one slot per logical RPC.
# The middleware writes the queue-wait duration to
# ``request.context[RPC_CONTEXT_RPC_QUEUE_WAIT_SECONDS]`` so the recorder
# below can forward it to ``ClientMetrics`` without giving the
# middleware an opinionated ``ClientMetrics`` dependency.
#
# Chain resolution is deferred to here — AFTER snapshot capture +
# materialization, immediately before dispatch — so a reassignment
# of ``chain_host._authed_post_chain`` that lands while the
# snapshot call awaits still steers this dispatch. Pre-extraction,
# the equivalent read happened at the dispatch site for the same
# live-binding reason; the provider closure preserves that timing.
chain = self._chain_provider()
if chain is None: # pragma: no cover - wiring bug guard
raise RuntimeError(
"RuntimeTransport.perform_authed_post called before the "
"wired chain was installed on MiddlewareChainHost; the "
"composition root must assign chain_host._authed_post_chain "
"before any authed POST."
)
try:
result = await chain(request)
return result.response
finally:
# Record queue wait even if the chain raised. A failed chain
# (RetryMiddleware budget exhaustion, AuthRefreshMiddleware
# refresh failure, etc.) MUST still surface the queue-wait
# latency. ``SemaphoreMiddleware`` writes the duration to
# ``request.context[RPC_CONTEXT_RPC_QUEUE_WAIT_SECONDS]`` after the
# semaphore is acquired; absence of the key means the slot
# was never acquired and there's nothing to record.
queue_wait = request.context.get(RPC_CONTEXT_RPC_QUEUE_WAIT_SECONDS)
if queue_wait is not None:
self._metrics.record_rpc_queue_wait(queue_wait)
__all__ = ["RuntimeTransport"]