Skip to main content

grpc_webnext/
schema.rs

1//! Descriptor source for `+json` termination.
2//!
3//! The proxy is schema-agnostic for binary `+proto` (it forwards opaque bytes), but
4//! turning `+json` into the binary protobuf an upstream expects needs the message
5//! descriptors. [`Schema`] resolves a [`Transcoder`] for a method path from one of
6//! three sources; the binary path never touches it.
7//!
8//! Reflection is loaded **eagerly and whole**: on startup the proxy enumerates every
9//! service the upstream exposes and builds a single transcoder covering all of them,
10//! then refreshes it on a TTL (and on an operator-forced [`Schema::reload`]). One
11//! consistent snapshot, rather than resolving each method lazily.
12
13use std::sync::Arc;
14use std::time::Duration;
15
16use bytes::Bytes;
17use crate::{TranscodeError, Transcoder};
18use tokio::sync::watch;
19use tonic::transport::Channel;
20use tonic::Status;
21
22use crate::reflect;
23
24/// How long a request will block waiting for the very first reflection load to land
25/// (subsequent loads are swapped in atomically, so requests never wait again).
26const FIRST_LOAD_WAIT: Duration = Duration::from_secs(10);
27/// Upper bound on a single reflection enumeration, so a wedged upstream can't hang the
28/// refresh task forever.
29const LOAD_TIMEOUT: Duration = Duration::from_secs(30);
30/// Retry cadence after a failed load (e.g. upstream not up yet), capped by the TTL.
31const RETRY_INTERVAL: Duration = Duration::from_secs(30);
32
33/// Where the proxy gets the descriptors it needs to transcode `+json`.
34#[derive(Clone, Default)]
35pub enum SchemaSource {
36    /// Binary-only: `+json` is rejected with `Unimplemented` (the default).
37    #[default]
38    None,
39    /// Fetch descriptors from the upstream's gRPC reflection service. The whole schema
40    /// is enumerated eagerly and refreshed on a TTL (see `ProxyConfig::reflection_ttl`).
41    Reflection,
42    /// A precompiled `FileDescriptorSet` (`protoc --descriptor_set_out` / prost
43    /// `file_descriptor_set_path`), for upstreams that don't expose reflection.
44    Bundled(Bytes),
45    /// Reflection primary, with the bundle as a fallback: the bundle serves immediately
46    /// (and if the upstream has no reflection at all), and the live reflection snapshot
47    /// takes over once it loads. Best of both — a baked-in safety net plus live
48    /// descriptors when the upstream supports reflection.
49    ReflectionOrBundled(Bytes),
50}
51
52/// Resolves a [`Transcoder`] for a gRPC method path according to a [`SchemaSource`].
53/// Cheap to clone; the reflection snapshot is shared.
54#[derive(Clone)]
55pub struct Schema {
56    inner: Inner,
57}
58
59#[derive(Clone)]
60enum Inner {
61    None,
62    /// One transcoder covers every method in the bundle.
63    Bundled(Arc<Transcoder>),
64    Reflection(Arc<ReflectionState>),
65    /// Reflection when its snapshot is loaded, else the bundle.
66    ReflectionOrBundled { reflection: Arc<ReflectionState>, bundle: Arc<Transcoder> },
67}
68
69/// The live reflection snapshot plus what's needed to refresh it. The current
70/// transcoder rides a `watch` channel: readers see the latest atomically, and a reader
71/// that arrives before the first load blocks on it.
72struct ReflectionState {
73    channel: Channel,
74    ttl: Duration,
75    tx: watch::Sender<Option<Arc<Transcoder>>>,
76    rx: watch::Receiver<Option<Arc<Transcoder>>>,
77}
78
79impl ReflectionState {
80    /// Enumerate the upstream and atomically swap in the new snapshot.
81    async fn load_and_swap(&self) -> Result<(), Status> {
82        let tc = match tokio::time::timeout(LOAD_TIMEOUT, reflect::load_all(&self.channel)).await {
83            Ok(result) => result?,
84            Err(_) => return Err(Status::deadline_exceeded("upstream reflection load timed out")),
85        };
86        let _ = self.tx.send(Some(Arc::new(tc)));
87        Ok(())
88    }
89
90    /// The current snapshot, or `None` if no load has landed yet (non-blocking).
91    fn current(&self) -> Option<Arc<Transcoder>> {
92        self.rx.borrow().clone()
93    }
94
95    /// The current transcoder, blocking (bounded) for the first load if none has landed.
96    async fn transcoder(&self) -> Result<Arc<Transcoder>, Status> {
97        let mut rx = self.rx.clone();
98        loop {
99            if let Some(tc) = rx.borrow_and_update().clone() {
100                return Ok(tc);
101            }
102            if tokio::time::timeout(FIRST_LOAD_WAIT, rx.changed()).await.is_err() {
103                return Err(Status::unavailable("descriptors not yet loaded from upstream reflection"));
104            }
105        }
106    }
107}
108
109/// Background task: load once, then keep the snapshot fresh on the TTL. A failed load
110/// keeps the previous snapshot (if any) and retries sooner than the TTL.
111async fn refresh_loop(state: Arc<ReflectionState>) {
112    loop {
113        let wait = match state.load_and_swap().await {
114            Ok(()) => {
115                tracing::info!("proxy: loaded +json descriptors from upstream reflection");
116                state.ttl
117            }
118            Err(e) => {
119                tracing::warn!("proxy: reflection load failed ({e}); will retry");
120                RETRY_INTERVAL.min(state.ttl)
121            }
122        };
123        tokio::time::sleep(wait).await;
124    }
125}
126
127impl Schema {
128    /// Build a schema from an already-constructed transcoder — the in-process case, where
129    /// the descriptors live in memory and there is no upstream to reflect against. `None`
130    /// means no `+json` transcoder is configured.
131    pub fn from_transcoder(transcoder: Option<Arc<Transcoder>>) -> Self {
132        Self { inner: transcoder.map(Inner::Bundled).unwrap_or(Inner::None) }
133    }
134
135    /// Build a resolver. The `Bundled` set is parsed eagerly so a bad descriptor set
136    /// fails at startup; `channel` is the upstream connection reused for reflection, and
137    /// `ttl` is the reflection refresh interval. Call [`Schema::start`] afterwards to
138    /// kick off the reflection loader.
139    pub fn build(source: SchemaSource, channel: Channel, ttl: Duration) -> Result<Self, TranscodeError> {
140        let inner = match source {
141            SchemaSource::None => Inner::None,
142            SchemaSource::Bundled(bytes) => {
143                Inner::Bundled(Arc::new(Transcoder::from_file_descriptor_set(&bytes)?))
144            }
145            SchemaSource::Reflection => {
146                let (tx, rx) = watch::channel(None);
147                Inner::Reflection(Arc::new(ReflectionState { channel, ttl, tx, rx }))
148            }
149            SchemaSource::ReflectionOrBundled(bytes) => {
150                let bundle = Arc::new(Transcoder::from_file_descriptor_set(&bytes)?);
151                let (tx, rx) = watch::channel(None);
152                Inner::ReflectionOrBundled {
153                    reflection: Arc::new(ReflectionState { channel, ttl, tx, rx }),
154                    bundle,
155                }
156            }
157        };
158        Ok(Self { inner })
159    }
160
161    /// Start the background reflection loader (eager initial load + TTL refresh). A
162    /// no-op unless the source involves reflection. Must be called from within a Tokio
163    /// runtime.
164    pub fn start(&self) {
165        let state = match &self.inner {
166            Inner::Reflection(state) => state,
167            Inner::ReflectionOrBundled { reflection, .. } => reflection,
168            _ => return,
169        };
170        tokio::spawn(refresh_loop(state.clone()));
171    }
172
173    /// Force an immediate reflection reload, returning its result. The management hook
174    /// behind `ProxyConfig::admin_reload_path`.
175    pub async fn reload(&self) -> Result<(), Status> {
176        match &self.inner {
177            Inner::Reflection(state) | Inner::ReflectionOrBundled { reflection: state, .. } => {
178                state.load_and_swap().await
179            }
180            Inner::None => Err(Status::failed_precondition("no descriptor source to reload")),
181            Inner::Bundled(_) => {
182                Err(Status::failed_precondition("bundled descriptors are static; nothing to reload"))
183            }
184        }
185    }
186
187    /// Whether this proxy can transcode `+json` at all.
188    pub fn enabled(&self) -> bool {
189        !matches!(self.inner, Inner::None)
190    }
191
192    /// The transcoder for this source, without validating any specific method — for
193    /// REST annotation matching, where the target method isn't known until the URL is
194    /// matched against the router.
195    pub async fn transcoder_any(&self) -> Result<Arc<Transcoder>, Status> {
196        match &self.inner {
197            Inner::None => Err(Status::unimplemented(
198                "no +json transcoder configured (pass a transcoder in-process, or enable upstream reflection / bundle a descriptor set on the proxy)",
199            )),
200            Inner::Bundled(tc) => Ok(tc.clone()),
201            Inner::Reflection(state) => state.transcoder().await,
202            // Prefer the live reflection snapshot; fall back to the bundle immediately
203            // (no wait) while reflection loads or if the upstream has no reflection.
204            Inner::ReflectionOrBundled { reflection, bundle } => {
205                Ok(reflection.current().unwrap_or_else(|| bundle.clone()))
206            }
207        }
208    }
209
210    /// Resolve the transcoder for `method_path` (`/pkg.Service/Method`). An unknown
211    /// method yields `Unimplemented` (uniformly across sources), distinct from a
212    /// transcode failure.
213    pub async fn transcoder(&self, method_path: &str) -> Result<Arc<Transcoder>, Status> {
214        let tc = self.transcoder_any().await?;
215        if tc.has_method(method_path) {
216            Ok(tc)
217        } else {
218            Err(Status::unimplemented(format!("no descriptor for method {method_path}")))
219        }
220    }
221}