Skip to main content

uqa_client/
http_engine_error.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7use std::fmt;
8use std::io;
9
10use crate::SQLDiagnostic;
11use reqwest::StatusCode;
12use thiserror::Error;
13
14/// Redacted failure returned by [`crate::HttpEngine`].
15#[derive(Error)]
16pub enum HttpEngineError {
17    #[error("UQA data-plane URL is invalid")]
18    InvalidBaseURL,
19    #[error("plain HTTP UQA URLs must resolve to loopback")]
20    InsecureRemoteURL,
21    #[error("UQA project token must not be empty")]
22    InvalidCredential,
23    #[error("required UQA connection environment variable {0} is missing")]
24    MissingEnvironmentVariable(&'static str),
25    #[error("UQA project name must not be empty")]
26    EmptyProjectName,
27    #[error("UQA organization name must not be empty")]
28    EmptyOrganizationName,
29    #[error("uqa CLI could not be started; install it or provide its executable path")]
30    CLIUnavailable(#[source] io::Error),
31    #[error("uqa CLI connection lookup failed")]
32    CLIExecution(#[source] io::Error),
33    #[error("uqa CLI connection lookup timed out")]
34    CLITimedOut,
35    #[error("uqa CLI connection output exceeded the client safety limit")]
36    CLIOutputTooLarge,
37    #[error("uqa CLI could not resolve the requested project; run the matching connection command for details")]
38    CLIConnectionFailed,
39    #[error("uqa CLI returned an invalid connection response")]
40    InvalidCLIResponse(#[source] serde_json::Error),
41    #[error("SQL text must not be empty")]
42    EmptySQL,
43    #[error("SQL parameter cannot be represented by the HTTP protocol")]
44    InvalidParameter,
45    #[error("UQA HTTP client could not be initialized")]
46    BuildClient(#[source] reqwest::Error),
47    #[error("UQA HTTP transport failed")]
48    Transport(#[source] reqwest::Error),
49    #[error("UQA returned {status} with code {code}{detail}", detail = diagnostic_message(.diagnostic.as_deref()))]
50    Server {
51        status: StatusCode,
52        code: String,
53        message: String,
54        request_id: Option<String>,
55        diagnostic: Option<Box<SQLDiagnostic>>,
56    },
57    #[error("UQA response exceeded the client safety limit")]
58    ResponseTooLarge,
59    #[error("UQA response content type is invalid")]
60    UnexpectedContentType,
61    #[error("UQA response is missing its request ID")]
62    MissingRequestId,
63    #[error("UQA response request IDs do not match")]
64    ResponseRequestIdMismatch,
65    #[error("UQA response body is not valid JSON")]
66    InvalidResponse(#[source] serde_json::Error),
67    #[error("UQA NDJSON stream frame exceeded the client safety limit")]
68    StreamFrameTooLarge,
69    #[error("UQA NDJSON stream frame order is invalid")]
70    InvalidStreamSequence,
71    #[error("UQA NDJSON stream ended before a terminal frame")]
72    TruncatedStream,
73    #[error("UQA NDJSON stream request ID does not match its HTTP response")]
74    StreamRequestIdMismatch,
75}
76
77impl HttpEngineError {
78    pub(crate) fn build_client(error: reqwest::Error) -> Self {
79        Self::BuildClient(error.without_url())
80    }
81
82    pub(crate) fn transport(error: reqwest::Error) -> Self {
83        Self::Transport(error.without_url())
84    }
85}
86
87impl fmt::Debug for HttpEngineError {
88    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self {
90            Self::Server {
91                status,
92                code,
93                request_id,
94                diagnostic,
95                ..
96            } => formatter
97                .debug_struct("HttpEngineError::Server")
98                .field("status", status)
99                .field("code", code)
100                .field("request_id", request_id)
101                .field("diagnostic", diagnostic)
102                .finish(),
103            Self::InvalidBaseURL => formatter.write_str("HttpEngineError::InvalidBaseURL"),
104            Self::InsecureRemoteURL => formatter.write_str("HttpEngineError::InsecureRemoteURL"),
105            Self::InvalidCredential => formatter.write_str("HttpEngineError::InvalidCredential"),
106            Self::MissingEnvironmentVariable(name) => formatter
107                .debug_tuple("HttpEngineError::MissingEnvironmentVariable")
108                .field(name)
109                .finish(),
110            Self::EmptyProjectName => formatter.write_str("HttpEngineError::EmptyProjectName"),
111            Self::EmptyOrganizationName => {
112                formatter.write_str("HttpEngineError::EmptyOrganizationName")
113            }
114            Self::CLIUnavailable(_) => {
115                formatter.write_str("HttpEngineError::CLIUnavailable([REDACTED])")
116            }
117            Self::CLIExecution(_) => {
118                formatter.write_str("HttpEngineError::CLIExecution([REDACTED])")
119            }
120            Self::CLITimedOut => formatter.write_str("HttpEngineError::CLITimedOut"),
121            Self::CLIOutputTooLarge => formatter.write_str("HttpEngineError::CLIOutputTooLarge"),
122            Self::CLIConnectionFailed => {
123                formatter.write_str("HttpEngineError::CLIConnectionFailed")
124            }
125            Self::InvalidCLIResponse(_) => {
126                formatter.write_str("HttpEngineError::InvalidCLIResponse([REDACTED])")
127            }
128            Self::EmptySQL => formatter.write_str("HttpEngineError::EmptySQL"),
129            Self::InvalidParameter => formatter.write_str("HttpEngineError::InvalidParameter"),
130            Self::BuildClient(_) => formatter.write_str("HttpEngineError::BuildClient([REDACTED])"),
131            Self::Transport(_) => formatter.write_str("HttpEngineError::Transport([REDACTED])"),
132            Self::ResponseTooLarge => formatter.write_str("HttpEngineError::ResponseTooLarge"),
133            Self::UnexpectedContentType => {
134                formatter.write_str("HttpEngineError::UnexpectedContentType")
135            }
136            Self::MissingRequestId => formatter.write_str("HttpEngineError::MissingRequestId"),
137            Self::ResponseRequestIdMismatch => {
138                formatter.write_str("HttpEngineError::ResponseRequestIdMismatch")
139            }
140            Self::InvalidResponse(_) => {
141                formatter.write_str("HttpEngineError::InvalidResponse([REDACTED])")
142            }
143            Self::StreamFrameTooLarge => {
144                formatter.write_str("HttpEngineError::StreamFrameTooLarge")
145            }
146            Self::InvalidStreamSequence => {
147                formatter.write_str("HttpEngineError::InvalidStreamSequence")
148            }
149            Self::TruncatedStream => formatter.write_str("HttpEngineError::TruncatedStream"),
150            Self::StreamRequestIdMismatch => {
151                formatter.write_str("HttpEngineError::StreamRequestIdMismatch")
152            }
153        }
154    }
155}
156
157fn diagnostic_message(diagnostic: Option<&SQLDiagnostic>) -> String {
158    diagnostic.map_or_else(String::new, |value| format!(" ({value})"))
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn server_debug_output_omits_customer_message() {
167        let secret = "customer SQL and result";
168        let error = HttpEngineError::Server {
169            status: StatusCode::BAD_REQUEST,
170            code: "SQL_EXECUTION_FAILED".to_owned(),
171            message: secret.to_owned(),
172            request_id: Some("qry_test".to_owned()),
173            diagnostic: None,
174        };
175
176        let debug = format!("{error:?}");
177        assert!(!debug.contains(secret));
178        assert!(debug.contains("SQL_EXECUTION_FAILED"));
179    }
180}