1use std::fmt;
4
5use thiserror::Error;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum AuthScope {
13 InvalidToken,
16 WrongAccountScope,
19 WorkersAiPermission,
22 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 #[error("Missing environment variable {env_var}. {hint}")]
42 MissingEnv { env_var: &'static str, hint: String },
43
44 #[error("Cloudflare API error (code {code}): {message}")]
46 Api { code: u32, message: String },
47
48 #[error("Cloudflare auth rejected ({kind}): {message} (code {code})")]
54 AuthRejected { kind: AuthScope, code: u32, message: String },
55
56 #[error("Cloudflare returned no OpenRouter-format data array at .data")]
58 NoDataArray,
59
60 #[error("Cloudflare request failed: {0}")]
62 Http(String),
63}
64
65impl CloudflareError {
66 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}