postjevsql.git / crates / postjevsql / src / failure.rs
1//! Every way a `jev*` call fails, and the one place each becomes a
2//! Postgres ERROR with a SQLSTATE a caller can branch on. The request id
3//! and attempt count go in DETAIL, so they are never lost to a message
4//! rewrite (contract *Execution*: every error carries the request id).
5
6use jev_client::{ClientError, TransportErrorKind};
7use jev_protocol::ApiErrorKind;
8use pgrx::PgSqlErrorCode::{self, *};
9use pgrx::PgLogLevel;
10use pgrx::pg_sys::panic::ErrorReport;
11
12pub enum Failure {
13    /// A setting is missing or unusable.
14    Setting(String),
15    /// No API key, or two.
16    Key(String),
17    /// The call itself.
18    Client(ClientError),
19    /// A `jev*` call outside the batch scan, which would run per row.
20    Unbatched(&'static str),
21    /// Over `jev.max_rows` or `jev.max_cost`, before sending.
22    Budget(String),
23    /// Reading or writing `jev_cache`.
24    Cache(String),
25    /// The equal judgment a call waited on was never answered.
26    Shared,
27    /// Under `jev.cache_only`, a judgment the cache has no answer for.
28    CacheOnly(String),
29    /// An EvalPlanQual recheck of a row version the statement did not
30    /// judge (a concurrent update changed what the call reads).
31    Recheck(String),
32}
33
34impl Failure {
35    pub fn report(self) -> ErrorReport {
36        let (code, message, detail, hint): (_, _, Option<String>, Option<&str>) = match self {
37            Failure::Setting(m) => (ERRCODE_INVALID_PARAMETER_VALUE, m, None, None),
38            Failure::Key(m) => (ERRCODE_INVALID_AUTHORIZATION_SPECIFICATION, m, None, None),
39            Failure::Client(e) => (code(&e), e.to_string(), detail(&e), None),
40            Failure::Unbatched(function) => (
41                ERRCODE_FEATURE_NOT_SUPPORTED,
42                format!("{function} is evaluated only by the jev scan, and this call is outside it"),
43                Some("Here it would send one request per row, one at a time.".into()),
44                Some("Use it in the WHERE clause, or the select list of a query over one relation, and not inside an aggregate or window function."),
45            ),
46            Failure::Budget(m) => (
47                ERRCODE_PROGRAM_LIMIT_EXCEEDED,
48                m,
49                Some("Nothing more was sent.".into()),
50                Some("Filter the rows further, add a LIMIT, or raise jev.max_rows or jev.max_cost."),
51            ),
52            Failure::Cache(m) => (ERRCODE_INTERNAL_ERROR, format!("jev_cache: {m}"), None, None),
53            Failure::CacheOnly(question) => (
54                ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE,
55                format!("jev.cache_only is on and the cache has no answer to {question:?} for this row"),
56                Some("Nothing was sent.".into()),
57                Some("Turn jev.cache_only off to ask Jev, or change jev.model or jev.cache_namespace back to the ones the answers were stored under."),
58            ),
59            Failure::Recheck(question) => (
60                ERRCODE_T_R_SERIALIZATION_FAILURE,
61                format!("a concurrent update changed a row this statement judged {question:?} about"),
62                Some("The row's new version was not judged, and a recheck sends nothing.".into()),
63                Some("Retry the statement."),
64            ),
65            Failure::Shared => (
66                ERRCODE_EXTERNAL_ROUTINE_EXCEPTION,
67                "the equal judgment this call shared a request with was not answered".into(),
68                None,
69                None,
70            ),
71        };
72        let mut report = ErrorReport::new(code, message, "jev");
73        if let Some(detail) = detail {
74            report = report.set_detail(detail);
75        }
76        if let Some(hint) = hint {
77            report = report.set_hint(hint);
78        }
79        report
80    }
81
82    /// Whether this is a failed judgment, which `jev.on_error = unsure`
83    /// answers NULL: the remote or the path to it failed on this row.
84    /// Everything that would fail every row the same way (settings, the
85    /// key, a request we built wrong, TLS, a moved model) and every guard
86    /// (the budget, `jev.cache_only`) still raises: turning those into
87    /// NULLs would hide a broken setup, or spend past a limit.
88    pub fn is_failed_judgment(&self) -> bool {
89        match self {
90            Failure::Client(e) => match e {
91                ClientError::Api { error, .. } => matches!(
92                    error.kind,
93                    ApiErrorKind::RequestTimeout
94                        | ApiErrorKind::ContextOverflow
95                        | ApiErrorKind::RateLimited
96                        | ApiErrorKind::Overloaded
97                        | ApiErrorKind::Server
98                ),
99                ClientError::Transport { error, .. } => matches!(
100                    error.kind,
101                    TransportErrorKind::NotSent
102                        | TransportErrorKind::Connect
103                        | TransportErrorKind::Interrupted
104                        | TransportErrorKind::TooLarge
105                ),
106                ClientError::TimedOut { .. } => true,
107                // Invalid, Response, and any kind added later: raise.
108                _ => false,
109            },
110            // The request it waited on failed; if that failure was not a
111            // failed judgment, the call that sent it raises it.
112            Failure::Shared => true,
113            Failure::Setting(_)
114            | Failure::Key(_)
115            | Failure::Unbatched(_)
116            | Failure::Budget(_)
117            | Failure::Cache(_)
118            | Failure::CacheOnly(_)
119            | Failure::Recheck(_) => false,
120        }
121    }
122
123    pub fn refusal(self) -> Box<ErrorReport> {
124        Box::new(self.report())
125    }
126
127    pub fn raise(self) -> ! {
128        self.report().report(PgLogLevel::ERROR);
129        unreachable!("an ERROR report does not return")
130    }
131}
132
133/// Classes follow Postgres's own use: postgres_fdw reports a remote it
134/// cannot reach under 08, and a remote that failed under 38.
135fn code(e: &ClientError) -> PgSqlErrorCode {
136    match e {
137        ClientError::Invalid(_) => ERRCODE_INVALID_PARAMETER_VALUE,
138        ClientError::Api { error, .. } => match error.kind {
139            ApiErrorKind::Authentication => ERRCODE_INVALID_AUTHORIZATION_SPECIFICATION,
140            // Too big for the model's context: a limit, not a fault.
141            ApiErrorKind::ContextOverflow => ERRCODE_PROGRAM_LIMIT_EXCEEDED,
142            // Throttled or overloaded: try later.
143            ApiErrorKind::RateLimited | ApiErrorKind::Overloaded => ERRCODE_INSUFFICIENT_RESOURCES,
144            _ => ERRCODE_EXTERNAL_ROUTINE_EXCEPTION,
145        },
146        ClientError::Transport { error, .. } => match error.kind {
147            TransportErrorKind::Config => ERRCODE_INVALID_PARAMETER_VALUE,
148            TransportErrorKind::TooLarge => ERRCODE_PROGRAM_LIMIT_EXCEEDED,
149            TransportErrorKind::Connect | TransportErrorKind::Tls => {
150                ERRCODE_SQLCLIENT_UNABLE_TO_ESTABLISH_SQLCONNECTION
151            }
152            _ => ERRCODE_CONNECTION_FAILURE,
153        },
154        ClientError::TimedOut { .. } => ERRCODE_CONNECTION_FAILURE,
155        ClientError::Response { .. } => ERRCODE_EXTERNAL_ROUTINE_EXCEPTION,
156        _ => ERRCODE_EXTERNAL_ROUTINE_EXCEPTION,
157    }
158}
159
160fn detail(e: &ClientError) -> Option<String> {
161    let attempts = match e {
162        ClientError::Api { attempts, .. }
163        | ClientError::Transport { attempts, .. }
164        | ClientError::TimedOut { attempts, .. } => Some(*attempts),
165        _ => None,
166    };
167    let parts: Vec<String> = [
168        e.request_id().map(|id| format!("request id {id}")),
169        attempts.map(|n| format!("{n} attempt{}", if n == 1 { "" } else { "s" })),
170    ]
171    .into_iter()
172    .flatten()
173    .collect();
174    (!parts.is_empty()).then(|| parts.join("; "))
175}