vgi_forge/error.rs
1//! The one error type every adapter returns.
2//!
3//! Variants describe what the *core* has to decide — retry, give up, ask a
4//! human, re-bind — rather than which HTTP status a forge happened to use, so
5//! the projector can act on a Forgejo failure and a GitHub failure the same
6//! way. Adapters keep the forge's own message in the payload for the audit log.
7
8use std::fmt;
9
10use vgi_core::ResourceError;
11
12/// Shorthand for adapter results.
13pub type Result<T, E = ForgeError> = std::result::Result<T, E>;
14
15/// A failed forge operation.
16#[derive(Debug, Clone, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum ForgeError {
19 /// The input is not a valid forge-qualified resource.
20 InvalidResource(ResourceError),
21 /// The resource is valid but not on this adapter's forge, or not the
22 /// shape the operation needs (a namespace where a repo was expected).
23 WrongResource {
24 /// What was given.
25 resource: String,
26 /// What the operation expected, in words.
27 expected: String,
28 },
29 /// No namespace binding covers this resource, so the adapter holds no
30 /// credential for it.
31 NotBound {
32 /// The namespace (`host/owner`) that has no binding.
33 namespace: String,
34 },
35 /// The forge (or this namespace on it) cannot do this — see
36 /// [`crate::Capabilities`]. `hint` says what a human can do instead.
37 Unsupported {
38 /// The operation that was refused.
39 operation: String,
40 /// Why, and what to do instead.
41 hint: String,
42 },
43 /// The resource does not exist, or the credential cannot see it (forges
44 /// deliberately do not distinguish the two).
45 NotFound {
46 /// What was looked up.
47 what: String,
48 },
49 /// Refused to create something that already exists. Carries the forge id
50 /// so the core can tell its own earlier attempt from a squatter.
51 AlreadyExists {
52 /// The resource that exists.
53 resource: String,
54 /// The forge's numeric id for it, when known.
55 forge_id: Option<u64>,
56 },
57 /// The forge answered with a redirect the adapter will not follow — a
58 /// renamed or transferred repository, usually.
59 Moved {
60 /// What was requested.
61 what: String,
62 /// Where the forge pointed.
63 location: String,
64 },
65 /// The forge rejected the adapter's credentials (expired, revoked, wrong
66 /// key). Re-binding or rotating the key is the fix, not a retry.
67 Unauthorized(String),
68 /// Authenticated, but not permitted — usually a permission the owner has
69 /// not approved on the installation.
70 Forbidden(String),
71 /// The forge refused the request as invalid or conflicting (a rule
72 /// violation, a validation failure).
73 Rejected {
74 /// The forge's status code.
75 status: u16,
76 /// The forge's message.
77 message: String,
78 },
79 /// Rate-limited. Retry after the given number of seconds, if the forge
80 /// said.
81 RateLimited {
82 /// Seconds to wait, when the forge said.
83 retry_after_secs: Option<u64>,
84 },
85 /// The forge could not be reached or failed (network, 5xx, timeout).
86 Unavailable(String),
87 /// A namespace bind callback did not match the bind that was started —
88 /// wrong or stale `state`, or an installation on the wrong owner.
89 BindRejected(String),
90 /// Linking a member's forge account did not complete (expired, denied).
91 LinkFailed(String),
92 /// A webhook failed verification or could not be parsed. Always treat as
93 /// hostile input: do not act on it.
94 Webhook(String),
95 /// The adapter or the bootstrap config is incomplete or invalid.
96 Config(String),
97 /// The forge answered with something the adapter does not understand.
98 Protocol(String),
99 /// A capability of the namespace turned out different from what was
100 /// planned with (a forge plan without a feature). The adapter has
101 /// updated its own copy; the caller persists the change and plans
102 /// again.
103 CapabilityChanged {
104 /// The namespace.
105 namespace: String,
106 /// Which [`crate::Capabilities`] field.
107 capability: String,
108 /// Its new value.
109 available: bool,
110 /// What the forge said.
111 reason: String,
112 },
113}
114
115impl ForgeError {
116 /// Whether retrying the same operation later can succeed without anyone
117 /// changing anything. Rejections, auth failures and bad input are not.
118 pub fn is_retryable(&self) -> bool {
119 matches!(
120 self,
121 ForgeError::RateLimited { .. } | ForgeError::Unavailable(_)
122 )
123 }
124}
125
126impl fmt::Display for ForgeError {
127 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128 match self {
129 ForgeError::InvalidResource(e) => write!(f, "{e}"),
130 ForgeError::WrongResource { resource, expected } => {
131 write!(f, "resource `{resource}`: expected {expected}")
132 }
133 ForgeError::NotBound { namespace } => write!(
134 f,
135 "namespace `{namespace}` is not bound to this bridge; bind it before managing \
136 its repositories"
137 ),
138 ForgeError::Unsupported { operation, hint } => {
139 write!(f, "{operation} is not supported here: {hint}")
140 }
141 ForgeError::NotFound { what } => write!(f, "{what}: not found (or not visible)"),
142 ForgeError::AlreadyExists { resource, forge_id } => match forge_id {
143 Some(id) => write!(f, "`{resource}` already exists (forge id {id})"),
144 None => write!(f, "`{resource}` already exists"),
145 },
146 ForgeError::Moved { what, location } => {
147 write!(
148 f,
149 "{what} has moved to {location} (renamed or transferred?)"
150 )
151 }
152 ForgeError::Unauthorized(m) => write!(f, "forge rejected the credentials: {m}"),
153 ForgeError::Forbidden(m) => write!(f, "forge refused the operation: {m}"),
154 ForgeError::Rejected { status, message } => {
155 write!(f, "forge rejected the request ({status}): {message}")
156 }
157 ForgeError::RateLimited { retry_after_secs } => match retry_after_secs {
158 Some(s) => write!(f, "rate-limited by the forge; retry in {s}s"),
159 None => write!(f, "rate-limited by the forge"),
160 },
161 ForgeError::Unavailable(m) => write!(f, "forge unavailable: {m}"),
162 ForgeError::BindRejected(m) => write!(f, "namespace bind rejected: {m}"),
163 ForgeError::LinkFailed(m) => write!(f, "account link failed: {m}"),
164 ForgeError::Webhook(m) => write!(f, "webhook rejected: {m}"),
165 ForgeError::Config(m) => write!(f, "configuration error: {m}"),
166 ForgeError::Protocol(m) => write!(f, "unexpected forge response: {m}"),
167 ForgeError::CapabilityChanged {
168 namespace,
169 capability,
170 available,
171 reason,
172 } => write!(
173 f,
174 "`{capability}` is now {available} for `{namespace}` ({reason}); plan again"
175 ),
176 }
177 }
178}
179
180impl std::error::Error for ForgeError {}
181
182impl From<ResourceError> for ForgeError {
183 fn from(e: ResourceError) -> Self {
184 ForgeError::InvalidResource(e)
185 }
186}