use std::fmt;
use std::time::{Duration, SystemTime};
use reqwest::{Method, StatusCode};
use serde::{Deserialize, Serialize};
pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("invalid GitHub config URL {url:?}: {reason}")]
InvalidConfigUrl {
url: String,
reason: String,
},
#[error("invalid credentials: {0}")]
InvalidCredentials(String),
#[error("invalid input: {0}")]
InvalidInput(String),
#[error(transparent)]
Api(Box<ApiError>),
#[error(transparent)]
RateLimited(Box<RateLimitError>),
#[error("request {method} {url} failed")]
Transport {
method: Method,
url: String,
#[source]
source: reqwest::Error,
},
#[error("request {method} {url} exceeded its retry budget of {budget:?}")]
MaxElapsedExceeded {
method: Method,
url: String,
budget: Duration,
},
#[error("failed to decode response of {method} {url}: {reason}")]
Decode {
method: Method,
url: String,
reason: String,
},
#[error("unexpected response: {0}")]
UnexpectedResponse(String),
#[error("failed to create a GitHub App JWT")]
Jwt(#[source] BoxError),
#[error("failed to build the HTTP client")]
HttpClient(#[source] reqwest::Error),
#[error("refusing to send credentials over plain HTTP to {url}")]
InsecureUrl {
url: String,
},
}
impl Error {
pub fn status(&self) -> Option<StatusCode> {
match self {
Self::Api(e) => Some(e.status),
Self::RateLimited(e) => Some(e.status),
_ => None,
}
}
pub fn api_error(&self) -> Option<&ApiError> {
match self {
Self::Api(e) => Some(e),
Self::RateLimited(e) => Some(&e.response),
_ => None,
}
}
pub fn rate_limit(&self) -> Option<&RateLimitError> {
match self {
Self::RateLimited(e) => Some(e),
_ => None,
}
}
fn kind(&self) -> Option<ApiErrorKind> {
self.api_error().map(|e| e.kind)
}
pub fn is_bad_request(&self) -> bool {
self.status() == Some(StatusCode::BAD_REQUEST)
}
pub fn is_unauthorized(&self) -> bool {
self.status() == Some(StatusCode::UNAUTHORIZED)
}
pub fn is_not_found(&self) -> bool {
self.status() == Some(StatusCode::NOT_FOUND)
}
pub fn is_conflict(&self) -> bool {
self.status() == Some(StatusCode::CONFLICT)
}
pub fn is_runner_not_found(&self) -> bool {
self.kind() == Some(ApiErrorKind::RunnerNotFound)
}
pub fn is_runner_exists(&self) -> bool {
self.kind() == Some(ApiErrorKind::RunnerExists)
}
pub fn is_job_still_running(&self) -> bool {
self.kind() == Some(ApiErrorKind::JobStillRunning)
}
pub fn is_message_queue_token_expired(&self) -> bool {
self.kind() == Some(ApiErrorKind::MessageQueueTokenExpired)
}
pub fn is_timeout(&self) -> bool {
match self {
Self::Transport { source, .. } => source.is_timeout(),
Self::MaxElapsedExceeded { .. } => true,
_ => false,
}
}
}
impl From<ApiError> for Error {
fn from(e: ApiError) -> Self {
Self::Api(Box::new(e))
}
}
impl From<RateLimitError> for Error {
fn from(e: RateLimitError) -> Self {
Self::RateLimited(Box::new(e))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ApiErrorKind {
RunnerExists,
RunnerNotFound,
JobStillRunning,
MessageQueueTokenExpired,
Other,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct ActionsException {
#[serde(rename = "typeName", default)]
pub type_name: String,
#[serde(default)]
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ApiError {
pub method: Method,
pub url: String,
pub status: StatusCode,
pub kind: ApiErrorKind,
pub activity_id: Option<String>,
pub github_request_id: Option<String>,
pub exception: Option<ActionsException>,
pub message: String,
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"request {} {} failed (status={}",
self.method, self.url, self.status
)?;
if let Some(id) = &self.activity_id {
write!(f, ", activity_id={id}")?;
}
if let Some(id) = &self.github_request_id {
write!(f, ", github_request_id={id}")?;
}
f.write_str(")")?;
if let Some(e) = &self.exception
&& !e.type_name.is_empty()
{
write!(f, ": {}", e.type_name)?;
}
if !self.message.is_empty() {
write!(f, ": {}", self.message)?;
}
Ok(())
}
}
impl std::error::Error for ApiError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum RateLimitKind {
Primary,
Secondary,
}
impl fmt::Display for RateLimitKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Primary => "primary",
Self::Secondary => "secondary",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct RateLimitError {
pub kind: RateLimitKind,
pub status: StatusCode,
pub retry_after: Option<Duration>,
pub reset: Option<SystemTime>,
pub retries: u32,
pub response: ApiError,
}
impl fmt::Display for RateLimitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} {} was rate limited ({})",
self.response.method, self.response.url, self.kind
)?;
if let Some(wait) = self.retry_after {
write!(f, "; retry after {wait:?}")?;
}
Ok(())
}
}
impl std::error::Error for RateLimitError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.response)
}
}