Coverage for agentos/models/error.py: 60%

95 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-09 09:19 +0800

1"""AgentOS Error Models — typed HTTP exceptions for the API layer. 

2 

3Provides a hierarchy of FastAPI-compatible HTTP exceptions with: 

4- Machine-readable error codes 

5- RFC 9457 problem detail fields 

6- Structured validation error accumulation 

7- Built-in logging integration 

8""" 

9 

10from __future__ import annotations 

11 

12import logging 

13from enum import StrEnum 

14 

15from fastapi import HTTPException 

16 

17logger = logging.getLogger(__name__) 

18 

19 

20class ErrorCode(StrEnum): 

21 """Machine-readable error codes for API responses.""" 

22 

23 # General 

24 INTERNAL_ERROR = "INTERNAL_ERROR" 

25 SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE" 

26 NOT_IMPLEMENTED = "NOT_IMPLEMENTED" 

27 

28 # Validation 

29 VALIDATION_ERROR = "VALIDATION_ERROR" 

30 INVALID_INPUT = "INVALID_INPUT" 

31 MISSING_REQUIRED = "MISSING_REQUIRED" 

32 TYPE_MISMATCH = "TYPE_MISMATCH" 

33 VALUE_OUT_OF_RANGE = "VALUE_OUT_OF_RANGE" 

34 

35 # Auth 

36 UNAUTHENTICATED = "UNAUTHENTICATED" 

37 UNAUTHORIZED = "UNAUTHORIZED" 

38 TOKEN_EXPIRED = "TOKEN_EXPIRED" 

39 TOKEN_INVALID = "TOKEN_INVALID" 

40 INSUFFICIENT_SCOPE = "INSUFFICIENT_SCOPE" 

41 

42 # Resource 

43 NOT_FOUND = "NOT_FOUND" 

44 CONFLICT = "CONFLICT" 

45 ALREADY_EXISTS = "ALREADY_EXISTS" 

46 GONE = "GONE" 

47 

48 # Rate limiting 

49 RATE_LIMITED = "RATE_LIMITED" 

50 QUOTA_EXCEEDED = "QUOTA_EXCEEDED" 

51 

52 # Agent-specific 

53 AGENT_NOT_FOUND = "AGENT_NOT_FOUND" 

54 AGENT_RUN_FAILED = "AGENT_RUN_FAILED" 

55 AGENT_TIMEOUT = "AGENT_TIMEOUT" 

56 TOOL_NOT_FOUND = "TOOL_NOT_FOUND" 

57 TOOL_EXECUTION_FAILED = "TOOL_EXECUTION_FAILED" 

58 

59 # Model / LLM 

60 MODEL_NOT_AVAILABLE = "MODEL_NOT_AVAILABLE" 

61 MODEL_TIMEOUT = "MODEL_TIMEOUT" 

62 CONTEXT_LENGTH_EXCEEDED = "CONTEXT_LENGTH_EXCEEDED" 

63 CONTENT_FILTER = "CONTENT_FILTER" 

64 

65 

66# ============================================================================ 

67# Base error 

68# ============================================================================ 

69 

70 

71class AgentOSError(HTTPException): 

72 """Base exception for all AgentOS API errors. 

73 

74 Extends FastAPI's HTTPException with structured error codes 

75 and optional field-level details. 

76 """ 

77 

78 def __init__( 

79 self, 

80 status_code: int, 

81 code: ErrorCode, 

82 detail: str = "", 

83 field: str | None = None, 

84 headers: dict[str, str] | None = None, 

85 ): 

86 super().__init__(status_code=status_code, detail=detail, headers=headers) 

87 self.code = code 

88 self.field = field 

89 self._log() 

90 

91 def _log(self): 

92 """Log the error at appropriate level.""" 

93 log_msg = f"[{self.code.value}] {self.detail}" 

94 if self.field: 

95 log_msg += f" (field: {self.field})" 

96 if self.status_code >= 500: 

97 logger.error(log_msg) 

98 else: 

99 logger.warning(log_msg) 

100 

101 def to_api_error(self) -> dict: 

102 """Convert to APIErrorDetail-compatible dict.""" 

103 return { 

104 "type": f"https://errors.agentos.dev/{self.code.value.lower()}", 

105 "title": self._title(), 

106 "status": self.status_code, 

107 "detail": self.detail, 

108 "code": self.code.value, 

109 "field": self.field, 

110 } 

111 

112 def _title(self) -> str: 

113 titles = { 

114 400: "Bad Request", 

115 401: "Unauthorized", 

116 403: "Forbidden", 

117 404: "Not Found", 

118 409: "Conflict", 

119 429: "Too Many Requests", 

120 500: "Internal Server Error", 

121 503: "Service Unavailable", 

122 } 

123 return titles.get(self.status_code, "Error") 

124 

125 

126# ============================================================================ 

127# Concrete error types 

128# ============================================================================ 

129 

130 

131class ValidationError(AgentOSError): 

132 """422 — Input validation failed. 

133 

134 Supports accumulating multiple field errors via add_error(). 

135 """ 

136 

137 def __init__( 

138 self, 

139 detail: str = "Validation failed", 

140 field: str | None = None, 

141 errors: list[dict[str, str]] | None = None, 

142 ): 

143 super().__init__( 

144 status_code=422, 

145 code=ErrorCode.VALIDATION_ERROR, 

146 detail=detail, 

147 field=field, 

148 ) 

149 self.errors: list[dict[str, str]] = errors or [] 

150 if field and detail: 

151 self.errors.append({"field": field, "message": detail}) 

152 

153 def add_error(self, field: str, message: str) -> None: 

154 """Accumulate an additional field error.""" 

155 self.errors.append({"field": field, "message": message}) 

156 if not self.detail: 

157 self.detail = f"Validation error on '{field}': {message}" 

158 

159 @property 

160 def has_errors(self) -> bool: 

161 return len(self.errors) > 0 

162 

163 def to_dict(self) -> dict: 

164 return { 

165 **self.to_api_error(), 

166 "errors": self.errors, 

167 } 

168 

169 

170class NotFoundError(AgentOSError): 

171 """404 — Resource not found.""" 

172 

173 def __init__(self, resource_type: str = "Resource", identifier: str = ""): 

174 detail = f"{resource_type} not found" 

175 if identifier: 

176 detail = f"{resource_type} '{identifier}' not found" 

177 super().__init__( 

178 status_code=404, 

179 code=ErrorCode.NOT_FOUND, 

180 detail=detail, 

181 ) 

182 

183 

184class AuthenticationError(AgentOSError): 

185 """401 — Missing or invalid credentials.""" 

186 

187 def __init__(self, detail: str = "Authentication required"): 

188 super().__init__( 

189 status_code=401, 

190 code=ErrorCode.UNAUTHENTICATED, 

191 detail=detail, 

192 ) 

193 

194 

195class AuthorizationError(AgentOSError): 

196 """403 — Insufficient permissions.""" 

197 

198 def __init__(self, detail: str = "Insufficient permissions"): 

199 super().__init__( 

200 status_code=403, 

201 code=ErrorCode.UNAUTHORIZED, 

202 detail=detail, 

203 ) 

204 

205 

206class RateLimitError(AgentOSError): 

207 """429 — Too many requests. 

208 

209 Includes retry-after header when available. 

210 """ 

211 

212 def __init__( 

213 self, 

214 detail: str = "Rate limit exceeded", 

215 retry_after: int | None = None, 

216 ): 

217 headers = {} 

218 if retry_after is not None: 

219 headers["Retry-After"] = str(retry_after) 

220 super().__init__( 

221 status_code=429, 

222 code=ErrorCode.RATE_LIMITED, 

223 detail=detail, 

224 headers=headers, 

225 ) 

226 self.retry_after = retry_after 

227 

228 

229class InternalError(AgentOSError): 

230 """500 — Unexpected internal error. User-safe, no stack traces exposed.""" 

231 

232 def __init__(self, detail: str = "An unexpected error occurred"): 

233 super().__init__( 

234 status_code=500, 

235 code=ErrorCode.INTERNAL_ERROR, 

236 detail=detail, 

237 ) 

238 

239 

240class ServiceUnavailableError(AgentOSError): 

241 """503 — Service temporarily unavailable (e.g., during maintenance).""" 

242 

243 def __init__( 

244 self, 

245 detail: str = "Service temporarily unavailable", 

246 retry_after: int | None = None, 

247 ): 

248 headers = {} 

249 if retry_after is not None: 

250 headers["Retry-After"] = str(retry_after) 

251 super().__init__( 

252 status_code=503, 

253 code=ErrorCode.SERVICE_UNAVAILABLE, 

254 detail=detail, 

255 headers=headers, 

256 )