Skip to main content

auth_cloudflare/
error.rs

1//! Errors - actionable, token-free Cloudflare provider errors.
2
3use std::fmt;
4
5use thiserror::Error;
6
7/// The scope classification of an authentication/authorization rejection,
8/// derived from the Cloudflare error envelope (feedback 01: invalid token vs
9/// wrong account scope vs Workers AI permission are distinct, actionable
10/// failures, never collapsed into a generic auth error).
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum AuthScope {
13	/// The API token is invalid, expired, or otherwise not a valid credential
14	/// (Cloudflare envelope code 9109).
15	InvalidToken,
16	/// The token is valid but not scoped to this account (envelope code 9103
17	/// "Account not found").
18	WrongAccountScope,
19	/// The token is valid and account-scoped but lacks the Workers AI
20	/// permission (envelope code 10000 "insufficient permission").
21	WorkersAiPermission,
22	/// Any auth rejection we could not classify further.
23	GenericAuth,
24}
25
26impl fmt::Display for AuthScope {
27	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28		let label = match self {
29			Self::InvalidToken => "invalid API token",
30			Self::WrongAccountScope => "wrong account scope",
31			Self::WorkersAiPermission => "Workers AI permission failure",
32			Self::GenericAuth => "authentication rejected",
33		};
34		f.write_str(label)
35	}
36}
37
38#[derive(Debug, Error)]
39pub enum CloudflareError {
40	/// A required environment variable is missing or empty.
41	#[error("Missing environment variable {env_var}. {hint}")]
42	MissingEnv { env_var: &'static str, hint: String },
43
44	/// Cloudflare returned an API error envelope (success: false).
45	#[error("Cloudflare API error (code {code}): {message}")]
46	Api { code: u32, message: String },
47
48	/// An authentication/authorization rejection classified by scope
49	/// (feedback 01): [`AuthScope::InvalidToken`] vs
50	/// [`AuthScope::WrongAccountScope`] vs [`AuthScope::WorkersAiPermission`]
51	/// are distinct, actionable failures. `message` is always token-scrubbed
52	/// before it reaches this variant.
53	#[error("Cloudflare auth rejected ({kind}): {message} (code {code})")]
54	AuthRejected { kind: AuthScope, code: u32, message: String },
55
56	/// The OpenRouter-format response had no `data` array.
57	#[error("Cloudflare returned no OpenRouter-format data array at .data")]
58	NoDataArray,
59
60	/// HTTP transport failure during a catalog or verify call.
61	#[error("Cloudflare request failed: {0}")]
62	Http(String),
63}
64
65impl CloudflareError {
66	/// True when the error came from the catalog endpoint (fetch_models
67	/// falls back to the static list on these).
68	pub fn is_catalog_failure(&self) -> bool {
69		matches!(
70			self,
71			Self::Api { .. } | Self::AuthRejected { .. } | Self::NoDataArray | Self::Http(_)
72		)
73	}
74}
75
76#[cfg(test)]
77mod tests {
78	use super::{AuthScope, CloudflareError};
79	use crate::auth::ACCOUNT_ENV;
80
81	#[test]
82	fn missing_env_names_the_var() {
83		let error = CloudflareError::MissingEnv { env_var: ACCOUNT_ENV, hint: "export it".to_string() };
84		let message = error.to_string();
85		assert!(message.contains(ACCOUNT_ENV));
86		assert!(!error.is_catalog_failure());
87	}
88
89	#[test]
90	fn catalog_failures_fall_back() {
91		assert!(CloudflareError::NoDataArray.is_catalog_failure());
92		assert!(CloudflareError::Http("timeout".to_string()).is_catalog_failure());
93		assert!(
94			CloudflareError::AuthRejected {
95				kind: AuthScope::InvalidToken,
96				code: 9109,
97				message: "Invalid access token".to_string(),
98			}
99			.is_catalog_failure(),
100			"a classified auth rejection must trigger the static-list fallback"
101		);
102		assert!(
103			!CloudflareError::MissingEnv { env_var: crate::auth::TOKEN_ENV, hint: String::new() }.is_catalog_failure()
104		);
105	}
106
107	#[test]
108	fn auth_rejected_display_is_token_free() {
109		let error = CloudflareError::AuthRejected {
110			kind: AuthScope::WorkersAiPermission,
111			code: 10000,
112			message: "insufficient permission".to_string(),
113		};
114		let rendered = error.to_string();
115		assert!(
116			rendered.contains("Workers AI permission failure"),
117			"scope is actionable: {rendered}"
118		);
119		assert!(rendered.contains("10000"), "envelope code survives: {rendered}");
120		assert!(!rendered.contains("cfut_"), "Display must never carry a token: {rendered}");
121		assert!(
122			!rendered.contains("Bearer"),
123			"Display must never carry a Bearer header: {rendered}"
124		);
125	}
126}