Skip to main content

faucet_sink_http/
sink.rs

1//! HTTP sink executor.
2
3use crate::config::{HttpBatchMode, HttpSinkAuth, HttpSinkConfig};
4use async_trait::async_trait;
5use faucet_core::util::{DEFAULT_ERROR_BODY_MAX_LEN, check_http_response};
6use faucet_core::{AuthSpec, Credential, FaucetError, SharedAuthProvider};
7use futures::stream::{FuturesUnordered, StreamExt};
8use serde_json::Value;
9use std::collections::HashMap;
10
11/// Map a [`Credential`] from a shared provider onto the [`HttpSinkAuth`]
12/// representation so the existing header-application path can be reused.
13fn credential_to_auth(cred: Credential) -> HttpSinkAuth {
14    match cred {
15        Credential::Bearer(token) => HttpSinkAuth::Bearer { token },
16        Credential::Token(token) => HttpSinkAuth::Custom {
17            headers: HashMap::from([("Authorization".to_string(), token)]),
18        },
19        Credential::Basic { username, password } => HttpSinkAuth::Basic { username, password },
20        Credential::Header { name, value } => HttpSinkAuth::Custom {
21            headers: HashMap::from([(name, value)]),
22        },
23    }
24}
25
26/// An HTTP sink that sends records to an HTTP endpoint.
27pub struct HttpSink {
28    config: HttpSinkConfig,
29    client: reqwest::Client,
30    /// Optional shared auth provider. When set, it takes precedence over inline
31    /// auth. Set via [`HttpSink::with_auth_provider`].
32    auth_provider: Option<SharedAuthProvider>,
33}
34
35impl HttpSink {
36    /// Create a new HTTP sink from the given configuration.
37    pub fn new(config: HttpSinkConfig) -> Self {
38        Self {
39            config,
40            client: reqwest::Client::new(),
41            auth_provider: None,
42        }
43    }
44
45    /// Attach a shared [`AuthProvider`](faucet_core::AuthProvider). When set,
46    /// the provider supplies the credential for every request (taking
47    /// precedence over inline auth), so several sinks can share one token with
48    /// single-flight refresh. Used by the CLI to resolve `auth: { ref }`, and
49    /// by library callers who construct one provider and inject it into many
50    /// sinks.
51    pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
52        self.auth_provider = Some(provider);
53        self
54    }
55
56    /// Resolve the effective auth for the current batch. The provider (if any)
57    /// takes precedence; otherwise inline auth is used. A bare
58    /// `AuthSpec::Reference` with no provider is an error.
59    async fn resolve_auth(&self) -> Result<HttpSinkAuth, FaucetError> {
60        if let Some(provider) = &self.auth_provider {
61            Ok(credential_to_auth(provider.credential().await?))
62        } else {
63            match &self.config.auth {
64                AuthSpec::Inline(a) => Ok(a.clone()),
65                AuthSpec::Reference(r) => Err(FaucetError::Auth(format!(
66                    "auth references provider '{}' but no provider was supplied; \
67                     set one via the CLI `auth:` catalog or `with_auth_provider`",
68                    r.name
69                ))),
70            }
71        }
72    }
73
74    /// Build an HTTP request with auth and headers applied.
75    fn apply_auth(
76        &self,
77        mut req: reqwest::RequestBuilder,
78        auth: &HttpSinkAuth,
79    ) -> Result<reqwest::RequestBuilder, FaucetError> {
80        match auth {
81            HttpSinkAuth::None => {}
82            HttpSinkAuth::Bearer { token } => {
83                req = req.bearer_auth(token);
84            }
85            HttpSinkAuth::Basic { username, password } => {
86                req = req.basic_auth(username, Some(password));
87            }
88            HttpSinkAuth::Custom { headers } => {
89                let mut hm = reqwest::header::HeaderMap::new();
90                for (name, value) in headers {
91                    let n =
92                        reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
93                            FaucetError::Auth(format!("invalid custom header name {name:?}: {e}"))
94                        })?;
95                    let v = reqwest::header::HeaderValue::from_str(value).map_err(|e| {
96                        FaucetError::Auth(format!("invalid custom header value for {name:?}: {e}"))
97                    })?;
98                    hm.insert(n, v);
99                }
100                req = req.headers(hm);
101            }
102        }
103        Ok(req)
104    }
105
106    /// Build an HTTP request with the given pre-resolved auth and body.
107    fn build_request_with_auth(
108        &self,
109        body: &Value,
110        auth: &HttpSinkAuth,
111    ) -> Result<reqwest::RequestBuilder, FaucetError> {
112        let req = self
113            .client
114            .request(self.config.method.clone(), &self.config.url)
115            .headers(self.config.headers.clone())
116            .json(body);
117        self.apply_auth(req, auth)
118    }
119
120    /// Send a single request with retry logic, using the pre-resolved `auth`.
121    async fn send_with_retry(&self, body: &Value, auth: &HttpSinkAuth) -> Result<(), FaucetError> {
122        let mut last_error = None;
123
124        for attempt in 0..=self.config.max_retries {
125            let req = self.build_request_with_auth(body, auth)?;
126
127            match req.send().await {
128                Ok(resp) => match check_http_response(resp, DEFAULT_ERROR_BODY_MAX_LEN).await {
129                    Ok(_) => return Ok(()),
130                    Err(e) => {
131                        if attempt < self.config.max_retries && e.is_retriable() {
132                            tracing::warn!(
133                                attempt = attempt + 1,
134                                max_retries = self.config.max_retries,
135                                error = %e,
136                                "retrying request"
137                            );
138                            last_error = Some(e);
139                            continue;
140                        }
141                        return Err(e);
142                    }
143                },
144                Err(e) => {
145                    let faucet_err = FaucetError::Http(e);
146                    if attempt < self.config.max_retries && faucet_err.is_retriable() {
147                        tracing::warn!(
148                            attempt = attempt + 1,
149                            max_retries = self.config.max_retries,
150                            error = %faucet_err,
151                            "retrying request"
152                        );
153                        last_error = Some(faucet_err);
154                        continue;
155                    }
156                    return Err(faucet_err);
157                }
158            }
159        }
160
161        Err(last_error.unwrap_or_else(|| FaucetError::Sink("max retries exhausted".into())))
162    }
163}
164
165#[async_trait]
166impl faucet_core::Sink for HttpSink {
167    fn config_schema(&self) -> serde_json::Value {
168        serde_json::to_value(faucet_core::schema_for!(HttpSinkConfig))
169            .expect("schema serialization")
170    }
171
172    fn dataset_uri(&self) -> String {
173        faucet_core::redact_uri_credentials(&self.config.url)
174    }
175
176    /// Non-mutating preflight probe (probe name `"network"`).
177    ///
178    /// Issues a lightweight `HEAD` request to the configured endpoint over the
179    /// existing reqwest client. We only care that the host is reachable — that
180    /// DNS, TCP, TLS and the server all work — so **any** HTTP response (2xx,
181    /// 4xx including `405 Method Not Allowed`, or 5xx) counts as a pass. Only a
182    /// transport/connection error (no response at all) is a failure.
183    async fn check(
184        &self,
185        ctx: &faucet_core::check::CheckContext,
186    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
187        use faucet_core::check::{CheckReport, Probe};
188
189        // Resolve auth so authenticated endpoints don't reject the connection
190        // before we learn the host is reachable. An unresolvable auth ref is a
191        // configuration failure surfaced on this probe.
192        let auth = match self.resolve_auth().await {
193            Ok(a) => a,
194            Err(e) => {
195                return Ok(CheckReport::single(Probe::fail_hint(
196                    "network",
197                    std::time::Duration::ZERO,
198                    e.to_string(),
199                    "check the configured auth / that a shared auth provider is wired up",
200                )));
201            }
202        };
203
204        let started = std::time::Instant::now();
205        let hint = "check the url / DNS / TLS / that the host is reachable";
206
207        let req = self
208            .client
209            .head(&self.config.url)
210            .headers(self.config.headers.clone());
211        let req = match self.apply_auth(req, &auth) {
212            Ok(r) => r,
213            Err(e) => {
214                return Ok(CheckReport::single(Probe::fail_hint(
215                    "network",
216                    started.elapsed(),
217                    e.to_string(),
218                    hint,
219                )));
220            }
221        };
222
223        let probe = match tokio::time::timeout(ctx.timeout, req.send()).await {
224            // Any HTTP response means DNS + TCP + TLS + the host all work.
225            Ok(Ok(_)) => Probe::pass("network", started.elapsed()),
226            // Transport/connection error: no response received.
227            Ok(Err(e)) => Probe::fail_hint("network", started.elapsed(), e.to_string(), hint),
228            Err(_) => Probe::fail_hint("network", started.elapsed(), "timed out", hint),
229        };
230        Ok(CheckReport::single(probe))
231    }
232
233    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
234        if records.is_empty() {
235            return Ok(0);
236        }
237
238        // Resolve auth once per batch (provider-first, then inline).
239        let auth = self.resolve_auth().await?;
240
241        match &self.config.batch_mode {
242            HttpBatchMode::Individual => {
243                // Run `send_with_retry` for every record with at most
244                // `concurrency` in-flight at once. We drive a
245                // `FuturesUnordered` directly, refilling it as each future
246                // completes, instead of acquiring permits up-front the way
247                // the previous semaphore-based code did — that approach
248                // deadlocked because permits were acquired sequentially in
249                // a loop before any future actually ran, so after
250                // `concurrency` iterations the next `acquire_owned().await`
251                // would block forever (closes #59).
252                let concurrency = self.config.concurrency.max(1);
253                let mut in_flight = FuturesUnordered::new();
254                let mut iter = records.iter();
255                for record in iter.by_ref().take(concurrency) {
256                    in_flight.push(self.send_with_retry(record, &auth));
257                }
258                while let Some(result) = in_flight.next().await {
259                    result?;
260                    if let Some(record) = iter.next() {
261                        in_flight.push(self.send_with_retry(record, &auth));
262                    }
263                }
264
265                tracing::debug!(records = records.len(), "HTTP individual batch written");
266                Ok(records.len())
267            }
268            HttpBatchMode::Array => {
269                // `batch_size = 0` is the "no batching" sentinel: forward
270                // whatever upstream handed us as a single JSON-array POST,
271                // preserving `StreamPage` framing. Otherwise re-chunk into
272                // `batch_size` slices and issue one POST per chunk.
273                let effective_chunk = if self.config.batch_size == 0 {
274                    records.len()
275                } else {
276                    self.config.batch_size
277                };
278
279                let mut total = 0;
280                for chunk in records.chunks(effective_chunk) {
281                    let array = Value::Array(chunk.to_vec());
282                    self.send_with_retry(&array, &auth).await?;
283                    total += chunk.len();
284                }
285                tracing::debug!(
286                    records = total,
287                    batch_size = self.config.batch_size,
288                    "HTTP array batch written"
289                );
290                Ok(total)
291            }
292        }
293    }
294
295    /// Report per-row outcomes so the DLQ router dead-letters only the records
296    /// that genuinely failed.
297    ///
298    /// In **Individual** mode every record is an independent POST, so each
299    /// record's success/failure is attributable: we attempt *all* of them
300    /// (unlike `write_batch`, whose `?` short-circuits on the first failure)
301    /// and return one `Ok`/`Err` per record. Without this
302    /// override the default impl would surface the first error as an outer
303    /// `Err`, and under `on_batch_error: dlq_all` the pipeline would route the
304    /// *entire* batch to the DLQ — duplicating the already-delivered rows
305    /// against a non-idempotent endpoint (#146 M14).
306    ///
307    /// In **Array** mode a single array POST cannot attribute a failure to
308    /// specific rows, so it stays all-or-nothing (matches the default trait
309    /// impl): the whole batch surfaces as an outer `Err` and the router's
310    /// `on_batch_error` policy decides whether to abort or dead-letter it.
311    async fn write_batch_partial(
312        &self,
313        records: &[Value],
314    ) -> Result<Vec<faucet_core::RowOutcome>, FaucetError> {
315        if records.is_empty() {
316            return Ok(Vec::new());
317        }
318
319        let auth = self.resolve_auth().await?;
320
321        match &self.config.batch_mode {
322            HttpBatchMode::Individual => {
323                let concurrency = self.config.concurrency.max(1);
324                let auth = &auth;
325                // Attempt every record (failures don't short-circuit the
326                // siblings) with at most `concurrency` POSTs in flight. Tag each
327                // outcome with its index so we can restore record order after
328                // the unordered completion. The per-record futures are built
329                // eagerly (lazy, not yet polled) so `buffer_unordered` drives a
330                // single concrete future type.
331                let pending: Vec<_> =
332                    records
333                        .iter()
334                        .enumerate()
335                        .map(|(idx, record)| async move {
336                            (idx, self.send_with_retry(record, auth).await)
337                        })
338                        .collect();
339                let mut indexed: Vec<(usize, faucet_core::RowOutcome)> =
340                    futures::stream::iter(pending)
341                        .buffer_unordered(concurrency)
342                        .collect()
343                        .await;
344                indexed.sort_by_key(|(idx, _)| *idx);
345                tracing::debug!(
346                    records = records.len(),
347                    "HTTP individual partial batch written"
348                );
349                Ok(indexed.into_iter().map(|(_, outcome)| outcome).collect())
350            }
351            HttpBatchMode::Array => {
352                self.write_batch(records).await?;
353                Ok(records.iter().map(|_| Ok(())).collect())
354            }
355        }
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use crate::config::HttpSinkConfig;
363    use faucet_core::Sink as _;
364
365    #[test]
366    fn dataset_uri_redacts_credentials() {
367        let config = HttpSinkConfig::new("https://user:secret@api.example.com/ingest");
368        let sink = HttpSink::new(config);
369        assert_eq!(sink.dataset_uri(), "https://api.example.com/ingest");
370    }
371
372    #[test]
373    fn creates_sink() {
374        let config = HttpSinkConfig::new("https://api.example.com/ingest");
375        let _sink = HttpSink::new(config);
376    }
377
378    #[test]
379    fn build_request_applies_bearer_auth() {
380        let auth = HttpSinkAuth::Bearer {
381            token: "my-token".into(),
382        };
383        let config = HttpSinkConfig::new("https://api.example.com/ingest").auth(auth.clone());
384        let sink = HttpSink::new(config);
385
386        let req = sink
387            .build_request_with_auth(&serde_json::json!({"test": true}), &auth)
388            .unwrap()
389            .build()
390            .unwrap();
391
392        let auth_header = req
393            .headers()
394            .get("authorization")
395            .unwrap()
396            .to_str()
397            .unwrap();
398        assert!(auth_header.starts_with("Bearer "));
399        assert!(auth_header.contains("my-token"));
400    }
401
402    #[test]
403    fn build_request_applies_basic_auth() {
404        let auth = HttpSinkAuth::Basic {
405            username: "user".into(),
406            password: "pass".into(),
407        };
408        let config = HttpSinkConfig::new("https://api.example.com/ingest").auth(auth.clone());
409        let sink = HttpSink::new(config);
410
411        let req = sink
412            .build_request_with_auth(&serde_json::json!({"test": true}), &auth)
413            .unwrap()
414            .build()
415            .unwrap();
416
417        let auth_header = req
418            .headers()
419            .get("authorization")
420            .unwrap()
421            .to_str()
422            .unwrap();
423        assert!(auth_header.starts_with("Basic "));
424    }
425
426    #[test]
427    fn build_request_uses_configured_method() {
428        let config =
429            HttpSinkConfig::new("https://api.example.com/ingest").method(reqwest::Method::PUT);
430        let sink = HttpSink::new(config);
431
432        let req = sink
433            .build_request_with_auth(&serde_json::json!({"test": true}), &HttpSinkAuth::None)
434            .unwrap()
435            .build()
436            .unwrap();
437
438        assert_eq!(req.method(), reqwest::Method::PUT);
439    }
440}