Line data Source code
1 : use measured::FixedCardinalityLabel;
2 : use serde::{Deserialize, Serialize};
3 : use std::fmt::{self, Display};
4 :
5 : use crate::auth::IpPattern;
6 :
7 : use crate::intern::{BranchIdInt, EndpointIdInt, ProjectIdInt};
8 : use crate::proxy::retry::CouldRetry;
9 :
10 : /// Generic error response with human-readable description.
11 : /// Note that we can't always present it to user as is.
12 0 : #[derive(Debug, Deserialize, Clone)]
13 : pub struct ConsoleError {
14 : pub error: Box<str>,
15 : #[serde(skip)]
16 : pub http_status_code: http::StatusCode,
17 : pub status: Option<Status>,
18 : }
19 :
20 : impl ConsoleError {
21 6 : pub fn get_reason(&self) -> Reason {
22 6 : self.status
23 6 : .as_ref()
24 6 : .and_then(|s| s.details.error_info.as_ref())
25 6 : .map_or(Reason::Unknown, |e| e.reason)
26 6 : }
27 :
28 0 : pub fn get_user_facing_message(&self) -> String {
29 0 : use super::provider::errors::REQUEST_FAILED;
30 0 : self.status
31 0 : .as_ref()
32 0 : .and_then(|s| s.details.user_facing_message.as_ref())
33 0 : .map_or_else(|| {
34 0 : // Ask @neondatabase/control-plane for review before adding more.
35 0 : match self.http_status_code {
36 : http::StatusCode::NOT_FOUND => {
37 : // Status 404: failed to get a project-related resource.
38 0 : format!("{REQUEST_FAILED}: endpoint cannot be found")
39 : }
40 : http::StatusCode::NOT_ACCEPTABLE => {
41 : // Status 406: endpoint is disabled (we don't allow connections).
42 0 : format!("{REQUEST_FAILED}: endpoint is disabled")
43 : }
44 : http::StatusCode::LOCKED | http::StatusCode::UNPROCESSABLE_ENTITY => {
45 : // Status 423: project might be in maintenance mode (or bad state), or quotas exceeded.
46 0 : format!("{REQUEST_FAILED}: endpoint is temporarily unavailable. Check your quotas and/or contact our support.")
47 : }
48 0 : _ => REQUEST_FAILED.to_owned(),
49 : }
50 0 : }, |m| m.message.clone().into())
51 0 : }
52 : }
53 :
54 : impl Display for ConsoleError {
55 0 : fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 0 : let msg: &str = self
57 0 : .status
58 0 : .as_ref()
59 0 : .and_then(|s| s.details.user_facing_message.as_ref())
60 0 : .map_or_else(|| self.error.as_ref(), |m| m.message.as_ref());
61 0 : write!(f, "{msg}")
62 0 : }
63 : }
64 :
65 : impl CouldRetry for ConsoleError {
66 12 : fn could_retry(&self) -> bool {
67 : // If the error message does not have a status,
68 : // the error is unknown and probably should not retry automatically
69 12 : let Some(status) = &self.status else {
70 4 : return false;
71 : };
72 :
73 : // retry if the retry info is set.
74 8 : if status.details.retry_info.is_some() {
75 8 : return true;
76 0 : }
77 0 :
78 0 : // if no retry info set, attempt to use the error code to guess the retry state.
79 0 : let reason = status
80 0 : .details
81 0 : .error_info
82 0 : .map_or(Reason::Unknown, |e| e.reason);
83 0 :
84 0 : reason.can_retry()
85 12 : }
86 : }
87 :
88 0 : #[derive(Debug, Deserialize, Clone)]
89 : pub struct Status {
90 : pub code: Box<str>,
91 : pub message: Box<str>,
92 : pub details: Details,
93 : }
94 :
95 0 : #[derive(Debug, Deserialize, Clone)]
96 : pub struct Details {
97 : pub error_info: Option<ErrorInfo>,
98 : pub retry_info: Option<RetryInfo>,
99 : pub user_facing_message: Option<UserFacingMessage>,
100 : }
101 :
102 0 : #[derive(Copy, Clone, Debug, Deserialize)]
103 : pub struct ErrorInfo {
104 : pub reason: Reason,
105 : // Schema could also have `metadata` field, but it's not structured. Skip it for now.
106 : }
107 :
108 0 : #[derive(Clone, Copy, Debug, Deserialize, Default)]
109 : pub enum Reason {
110 : /// RoleProtected indicates that the role is protected and the attempted operation is not permitted on protected roles.
111 : #[serde(rename = "ROLE_PROTECTED")]
112 : RoleProtected,
113 : /// ResourceNotFound indicates that a resource (project, endpoint, branch, etc.) wasn't found,
114 : /// usually due to the provided ID not being correct or because the subject doesn't have enough permissions to
115 : /// access the requested resource.
116 : /// Prefer a more specific reason if possible, e.g., ProjectNotFound, EndpointNotFound, etc.
117 : #[serde(rename = "RESOURCE_NOT_FOUND")]
118 : ResourceNotFound,
119 : /// ProjectNotFound indicates that the project wasn't found, usually due to the provided ID not being correct,
120 : /// or that the subject doesn't have enough permissions to access the requested project.
121 : #[serde(rename = "PROJECT_NOT_FOUND")]
122 : ProjectNotFound,
123 : /// EndpointNotFound indicates that the endpoint wasn't found, usually due to the provided ID not being correct,
124 : /// or that the subject doesn't have enough permissions to access the requested endpoint.
125 : #[serde(rename = "ENDPOINT_NOT_FOUND")]
126 : EndpointNotFound,
127 : /// BranchNotFound indicates that the branch wasn't found, usually due to the provided ID not being correct,
128 : /// or that the subject doesn't have enough permissions to access the requested branch.
129 : #[serde(rename = "BRANCH_NOT_FOUND")]
130 : BranchNotFound,
131 : /// RateLimitExceeded indicates that the rate limit for the operation has been exceeded.
132 : #[serde(rename = "RATE_LIMIT_EXCEEDED")]
133 : RateLimitExceeded,
134 : /// NonDefaultBranchComputeTimeExceeded indicates that the compute time quota of non-default branches has been
135 : /// exceeded.
136 : #[serde(rename = "NON_PRIMARY_BRANCH_COMPUTE_TIME_EXCEEDED")]
137 : NonDefaultBranchComputeTimeExceeded,
138 : /// ActiveTimeQuotaExceeded indicates that the active time quota was exceeded.
139 : #[serde(rename = "ACTIVE_TIME_QUOTA_EXCEEDED")]
140 : ActiveTimeQuotaExceeded,
141 : /// ComputeTimeQuotaExceeded indicates that the compute time quota was exceeded.
142 : #[serde(rename = "COMPUTE_TIME_QUOTA_EXCEEDED")]
143 : ComputeTimeQuotaExceeded,
144 : /// WrittenDataQuotaExceeded indicates that the written data quota was exceeded.
145 : #[serde(rename = "WRITTEN_DATA_QUOTA_EXCEEDED")]
146 : WrittenDataQuotaExceeded,
147 : /// DataTransferQuotaExceeded indicates that the data transfer quota was exceeded.
148 : #[serde(rename = "DATA_TRANSFER_QUOTA_EXCEEDED")]
149 : DataTransferQuotaExceeded,
150 : /// LogicalSizeQuotaExceeded indicates that the logical size quota was exceeded.
151 : #[serde(rename = "LOGICAL_SIZE_QUOTA_EXCEEDED")]
152 : LogicalSizeQuotaExceeded,
153 : /// RunningOperations indicates that the project already has some running operations
154 : /// and scheduling of new ones is prohibited.
155 : #[serde(rename = "RUNNING_OPERATIONS")]
156 : RunningOperations,
157 : /// ConcurrencyLimitReached indicates that the concurrency limit for an action was reached.
158 : #[serde(rename = "CONCURRENCY_LIMIT_REACHED")]
159 : ConcurrencyLimitReached,
160 : /// LockAlreadyTaken indicates that the we attempted to take a lock that was already taken.
161 : #[serde(rename = "LOCK_ALREADY_TAKEN")]
162 : LockAlreadyTaken,
163 : #[default]
164 : #[serde(other)]
165 : Unknown,
166 : }
167 :
168 : impl Reason {
169 0 : pub fn is_not_found(&self) -> bool {
170 0 : matches!(
171 0 : self,
172 : Reason::ResourceNotFound
173 : | Reason::ProjectNotFound
174 : | Reason::EndpointNotFound
175 : | Reason::BranchNotFound
176 : )
177 0 : }
178 :
179 0 : pub fn can_retry(&self) -> bool {
180 0 : match self {
181 : // do not retry role protected errors
182 : // not a transitive error
183 0 : Reason::RoleProtected => false,
184 : // on retry, it will still not be found
185 : Reason::ResourceNotFound
186 : | Reason::ProjectNotFound
187 : | Reason::EndpointNotFound
188 0 : | Reason::BranchNotFound => false,
189 : // we were asked to go away
190 : Reason::RateLimitExceeded
191 : | Reason::NonDefaultBranchComputeTimeExceeded
192 : | Reason::ActiveTimeQuotaExceeded
193 : | Reason::ComputeTimeQuotaExceeded
194 : | Reason::WrittenDataQuotaExceeded
195 : | Reason::DataTransferQuotaExceeded
196 0 : | Reason::LogicalSizeQuotaExceeded => false,
197 : // transitive error. control plane is currently busy
198 : // but might be ready soon
199 : Reason::RunningOperations
200 : | Reason::ConcurrencyLimitReached
201 0 : | Reason::LockAlreadyTaken => true,
202 : // unknown error. better not retry it.
203 0 : Reason::Unknown => false,
204 : }
205 0 : }
206 : }
207 :
208 0 : #[derive(Copy, Clone, Debug, Deserialize)]
209 : pub struct RetryInfo {
210 : pub retry_delay_ms: u64,
211 : }
212 :
213 0 : #[derive(Debug, Deserialize, Clone)]
214 : pub struct UserFacingMessage {
215 : pub message: Box<str>,
216 : }
217 :
218 : /// Response which holds client's auth secret, e.g. [`crate::scram::ServerSecret`].
219 : /// Returned by the `/proxy_get_role_secret` API method.
220 18 : #[derive(Deserialize)]
221 : pub struct GetRoleSecret {
222 : pub role_secret: Box<str>,
223 : pub allowed_ips: Option<Vec<IpPattern>>,
224 : pub project_id: Option<ProjectIdInt>,
225 : }
226 :
227 : // Manually implement debug to omit sensitive info.
228 : impl fmt::Debug for GetRoleSecret {
229 0 : fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230 0 : f.debug_struct("GetRoleSecret").finish_non_exhaustive()
231 0 : }
232 : }
233 :
234 : /// Response which holds compute node's `host:port` pair.
235 : /// Returned by the `/proxy_wake_compute` API method.
236 6 : #[derive(Debug, Deserialize)]
237 : pub struct WakeCompute {
238 : pub address: Box<str>,
239 : pub aux: MetricsAuxInfo,
240 : }
241 :
242 : /// Async response which concludes the link auth flow.
243 : /// Also known as `kickResponse` in the console.
244 8 : #[derive(Debug, Deserialize)]
245 : pub struct KickSession<'a> {
246 : /// Session ID is assigned by the proxy.
247 : pub session_id: &'a str,
248 :
249 : /// Compute node connection params.
250 : #[serde(deserialize_with = "KickSession::parse_db_info")]
251 : pub result: DatabaseInfo,
252 : }
253 :
254 : impl KickSession<'_> {
255 2 : fn parse_db_info<'de, D>(des: D) -> Result<DatabaseInfo, D::Error>
256 2 : where
257 2 : D: serde::Deserializer<'de>,
258 2 : {
259 4 : #[derive(Deserialize)]
260 2 : enum Wrapper {
261 2 : // Currently, console only reports `Success`.
262 2 : // `Failure(String)` used to be here... RIP.
263 2 : Success(DatabaseInfo),
264 2 : }
265 2 :
266 2 : Wrapper::deserialize(des).map(|x| match x {
267 2 : Wrapper::Success(info) => info,
268 2 : })
269 2 : }
270 : }
271 :
272 : /// Compute node connection params.
273 56 : #[derive(Deserialize)]
274 : pub struct DatabaseInfo {
275 : pub host: Box<str>,
276 : pub port: u16,
277 : pub dbname: Box<str>,
278 : pub user: Box<str>,
279 : /// Console always provides a password, but it might
280 : /// be inconvenient for debug with local PG instance.
281 : pub password: Option<Box<str>>,
282 : pub aux: MetricsAuxInfo,
283 : }
284 :
285 : // Manually implement debug to omit sensitive info.
286 : impl fmt::Debug for DatabaseInfo {
287 0 : fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288 0 : f.debug_struct("DatabaseInfo")
289 0 : .field("host", &self.host)
290 0 : .field("port", &self.port)
291 0 : .field("dbname", &self.dbname)
292 0 : .field("user", &self.user)
293 0 : .finish_non_exhaustive()
294 0 : }
295 : }
296 :
297 : /// Various labels for prometheus metrics.
298 : /// Also known as `ProxyMetricsAuxInfo` in the console.
299 50 : #[derive(Debug, Deserialize, Clone)]
300 : pub struct MetricsAuxInfo {
301 : pub endpoint_id: EndpointIdInt,
302 : pub project_id: ProjectIdInt,
303 : pub branch_id: BranchIdInt,
304 : #[serde(default)]
305 : pub cold_start_info: ColdStartInfo,
306 : }
307 :
308 20 : #[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, FixedCardinalityLabel)]
309 : #[serde(rename_all = "snake_case")]
310 : pub enum ColdStartInfo {
311 : #[default]
312 : Unknown,
313 : /// Compute was already running
314 : Warm,
315 : #[serde(rename = "pool_hit")]
316 : #[label(rename = "pool_hit")]
317 : /// Compute was not running but there was an available VM
318 : VmPoolHit,
319 : #[serde(rename = "pool_miss")]
320 : #[label(rename = "pool_miss")]
321 : /// Compute was not running and there were no VMs available
322 : VmPoolMiss,
323 :
324 : // not provided by control plane
325 : /// Connection available from HTTP pool
326 : HttpPoolHit,
327 : /// Cached connection info
328 : WarmCached,
329 : }
330 :
331 : impl ColdStartInfo {
332 0 : pub fn as_str(&self) -> &'static str {
333 0 : match self {
334 0 : ColdStartInfo::Unknown => "unknown",
335 0 : ColdStartInfo::Warm => "warm",
336 0 : ColdStartInfo::VmPoolHit => "pool_hit",
337 0 : ColdStartInfo::VmPoolMiss => "pool_miss",
338 0 : ColdStartInfo::HttpPoolHit => "http_pool_hit",
339 0 : ColdStartInfo::WarmCached => "warm_cached",
340 : }
341 0 : }
342 : }
343 :
344 : #[cfg(test)]
345 : mod tests {
346 : use super::*;
347 : use serde_json::json;
348 :
349 10 : fn dummy_aux() -> serde_json::Value {
350 10 : json!({
351 10 : "endpoint_id": "endpoint",
352 10 : "project_id": "project",
353 10 : "branch_id": "branch",
354 10 : "cold_start_info": "unknown",
355 10 : })
356 10 : }
357 :
358 : #[test]
359 2 : fn parse_kick_session() -> anyhow::Result<()> {
360 2 : // This is what the console's kickResponse looks like.
361 2 : let json = json!({
362 2 : "session_id": "deadbeef",
363 2 : "result": {
364 2 : "Success": {
365 2 : "host": "localhost",
366 2 : "port": 5432,
367 2 : "dbname": "postgres",
368 2 : "user": "john_doe",
369 2 : "password": "password",
370 2 : "aux": dummy_aux(),
371 2 : }
372 2 : }
373 2 : });
374 2 : let _: KickSession<'_> = serde_json::from_str(&json.to_string())?;
375 :
376 2 : Ok(())
377 2 : }
378 :
379 : #[test]
380 2 : fn parse_db_info() -> anyhow::Result<()> {
381 : // with password
382 2 : let _: DatabaseInfo = serde_json::from_value(json!({
383 2 : "host": "localhost",
384 2 : "port": 5432,
385 2 : "dbname": "postgres",
386 2 : "user": "john_doe",
387 2 : "password": "password",
388 2 : "aux": dummy_aux(),
389 2 : }))?;
390 :
391 : // without password
392 2 : let _: DatabaseInfo = serde_json::from_value(json!({
393 2 : "host": "localhost",
394 2 : "port": 5432,
395 2 : "dbname": "postgres",
396 2 : "user": "john_doe",
397 2 : "aux": dummy_aux(),
398 2 : }))?;
399 :
400 : // new field (forward compatibility)
401 2 : let _: DatabaseInfo = serde_json::from_value(json!({
402 2 : "host": "localhost",
403 2 : "port": 5432,
404 2 : "dbname": "postgres",
405 2 : "user": "john_doe",
406 2 : "project": "hello_world",
407 2 : "N.E.W": "forward compatibility check",
408 2 : "aux": dummy_aux(),
409 2 : }))?;
410 :
411 2 : Ok(())
412 2 : }
413 :
414 : #[test]
415 2 : fn parse_wake_compute() -> anyhow::Result<()> {
416 2 : let json = json!({
417 2 : "address": "0.0.0.0",
418 2 : "aux": dummy_aux(),
419 2 : });
420 2 : let _: WakeCompute = serde_json::from_str(&json.to_string())?;
421 2 : Ok(())
422 2 : }
423 :
424 : #[test]
425 2 : fn parse_get_role_secret() -> anyhow::Result<()> {
426 2 : // Empty `allowed_ips` field.
427 2 : let json = json!({
428 2 : "role_secret": "secret",
429 2 : });
430 2 : let _: GetRoleSecret = serde_json::from_str(&json.to_string())?;
431 2 : let json = json!({
432 2 : "role_secret": "secret",
433 2 : "allowed_ips": ["8.8.8.8"],
434 2 : });
435 2 : let _: GetRoleSecret = serde_json::from_str(&json.to_string())?;
436 2 : let json = json!({
437 2 : "role_secret": "secret",
438 2 : "allowed_ips": ["8.8.8.8"],
439 2 : "project_id": "project",
440 2 : });
441 2 : let _: GetRoleSecret = serde_json::from_str(&json.to_string())?;
442 :
443 2 : Ok(())
444 2 : }
445 : }
|