Skip to main content

pingap_core/
lib.rs

1// Copyright 2024-2025 Tree xie.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use snafu::Snafu;
16
17static LOG_TARGET: &str = "pingap::core";
18
19#[derive(Debug, Snafu)]
20pub enum Error {
21    #[snafu(display("invalid error, {message}"))]
22    Invalid { message: String },
23    #[snafu(display("{category} not found"))]
24    NotFound { category: String },
25}
26
27/// Creates a new internal error
28pub fn new_internal_error(
29    status: u16,
30    message: impl ToString,
31) -> pingora::BError {
32    pingora::Error::because(
33        pingora::ErrorType::HTTPStatus(status),
34        message.to_string(),
35        pingora::Error::new(pingora::ErrorType::InternalError),
36    )
37}
38
39/// The cause of an error that stands for a response of the upstream, see
40/// [`new_upstream_status_error`].
41const UPSTREAM_STATUS: pingora::ErrorType =
42    pingora::ErrorType::Custom("UpstreamStatus");
43
44/// The error a plugin stops a response of the upstream with, for the
45/// proxy to answer `status` itself - with the error page of the location -
46/// in place of what the upstream sent (`error_page` with `intercept`).
47///
48/// It is the answer of the upstream and no failure of the proxy, which
49/// [`is_upstream_status_error`] tells apart: it is not retried, not
50/// answered with a stale response of the cache, and not logged as an
51/// error.
52pub fn new_upstream_status_error(status: http::StatusCode) -> pingora::BError {
53    pingora::Error::because(
54        pingora::ErrorType::HTTPStatus(status.as_u16()),
55        status.canonical_reason().unwrap_or("Unknown Error"),
56        pingora::Error::new(UPSTREAM_STATUS),
57    )
58}
59
60/// Whether `e` is an error of [`new_upstream_status_error`].
61pub fn is_upstream_status_error(e: &pingora::Error) -> bool {
62    matches!(e.etype(), pingora::ErrorType::HTTPStatus(_))
63        && e.root_etype() == &UPSTREAM_STATUS
64}
65
66/// The names `access_log` accepts in place of a format. Here, and not with
67/// the formats themselves in `pingap-logger`, so that the config validation
68/// knows a preset from a word that is none.
69pub const ACCESS_LOG_PRESETS: [&str; 5] =
70    ["combined", "common", "short", "tiny", "json"];
71
72mod ctx;
73mod http_header;
74mod http_response;
75mod identity;
76pub mod ja4;
77mod notification;
78mod plugin;
79mod proxy_protocol;
80mod service;
81mod ttl_lru_limit;
82mod util;
83
84pub use ctx::*;
85pub use http_header::*;
86pub use http_response::*;
87pub use identity::*;
88pub use ja4::{Ja4Fingerprint, is_grease};
89pub use notification::*;
90pub use pingora_limits::inflight::{Guard, Inflight};
91pub use pingora_limits::rate::Rate;
92pub use plugin::*;
93pub use proxy_protocol::*;
94pub use service::*;
95pub use tinyufo::TinyUfo;
96pub use ttl_lru_limit::*;
97pub use util::*;
98
99/// Builds an HTTP/1 session that has read `GET <url>` with `headers`, for
100/// tests that need a real `Session` without a socket.
101#[cfg(test)]
102pub(crate) async fn new_test_session(
103    headers: &[&str],
104    url: &str,
105) -> pingora::proxy::Session {
106    let headers = headers.join("\r\n");
107    let input_header = format!("GET {url} HTTP/1.1\r\n{headers}\r\n\r\n");
108    let mock_io = tokio_test::io::Builder::new()
109        .read(input_header.as_bytes())
110        .build();
111    let mut session = pingora::proxy::Session::new_h1(Box::new(mock_io));
112    session
113        .read_request()
114        .await
115        .expect("the test request must parse");
116    session
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn test_new_internal_error() {
125        let err = new_internal_error(500, "Internal Server Error");
126        assert_eq!(
127            err.to_string().trim(),
128            "HTTPStatus context: Internal Server Error cause:  InternalError"
129        );
130    }
131
132    /// The error that stands for a response of the upstream is told from
133    /// one the proxy raised, also from under what it is wrapped in.
134    #[test]
135    fn test_upstream_status_error() {
136        let err = new_upstream_status_error(http::StatusCode::NOT_FOUND);
137        assert_eq!(&pingora::ErrorType::HTTPStatus(404), err.etype());
138        assert_eq!(
139            err.to_string().trim(),
140            "HTTPStatus context: Not Found cause:  UpstreamStatus"
141        );
142        assert!(is_upstream_status_error(&err));
143        assert!(!err.retry());
144        assert!(is_upstream_status_error(&err.more_context("Peer: a")));
145
146        assert!(!is_upstream_status_error(&new_internal_error(404, "x")));
147        assert!(!is_upstream_status_error(&pingora::Error::new(
148            pingora::ErrorType::ConnectRefused
149        )));
150        // a status that has no name
151        let err =
152            new_upstream_status_error(http::StatusCode::from_u16(499).unwrap());
153        assert!(err.to_string().contains("Unknown Error"));
154    }
155
156    #[tokio::test]
157    async fn test_new_test_session() {
158        let session = new_test_session(
159            &["Host: github.com", "user-agent: pingap/0.1.1"],
160            "/",
161        )
162        .await;
163        assert_eq!(
164            b"pingap/0.1.1",
165            session.get_header("user-agent").unwrap().as_bytes()
166        );
167        assert_eq!("/", session.req_header().uri.path());
168    }
169}