Repository navigation
Expand file tree
/
Copy pathapi_errors.py
More file actions
207 lines (134 loc) · 5.06 KB
/
Copy pathapi_errors.py
File metadata and controls
207 lines (134 loc) · 5.06 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
"""Structured API errors with stable machine-readable codes.
Handlers raise these; the dispatcher maps each class to an HTTP status and a JSON body with error/code/request_id.
"""
class ApiError(Exception):
"""Base class for all structured API errors."""
status = 500
code = "INTERNAL_ERROR"
def __init__(self, message, *, code=None, detail=None):
super().__init__(message)
self.message = str(message)
self.code = code or self.code
self.detail = detail
def to_payload(self, request_id=""):
payload = {"error": self.message, "code": self.code}
if self.detail is not None:
payload["detail"] = self.detail
if request_id:
payload["request_id"] = request_id
return payload
class BadRequest(ApiError):
status = 400
code = "BAD_REQUEST"
class V1ApiRemoved(ApiError):
"""The /api/v1 surface was removed in 2.1 (ADR 0103)."""
status = 410
code = "V1_API_REMOVED"
class ApiVersionRequired(ApiError):
"""An unversioned API path is no longer served; use its /api/v2 twin (ADR 0103)."""
status = 410
code = "API_VERSION_REQUIRED"
class HostChannelRetired(ApiError):
"""A legacy native-host path was removed; hosts must use /api/host (ADR 0103)."""
status = 410
code = "HOST_CHANNEL_RETIRED"
class NotFound(ApiError):
status = 404
code = "NOT_FOUND"
class GameNotFound(NotFound):
code = "GAME_NOT_FOUND"
class MediaNotFound(NotFound):
code = "MEDIA_NOT_FOUND"
class DocumentNotFound(NotFound):
code = "DOCUMENT_NOT_FOUND"
class BadgeNotFound(NotFound):
code = "BADGE_NOT_FOUND"
class PlatformDocumentNotFound(NotFound):
code = "PLATFORM_DOCUMENT_NOT_FOUND"
class RouteNotFound(NotFound):
code = "ROUTE_NOT_FOUND"
class Conflict(ApiError):
status = 409
code = "CONFLICT"
class JobStateConflict(Conflict):
code = "JOB_STATE_CONFLICT"
class JobNotCancellable(Conflict):
code = "JOB_NOT_CANCELLABLE"
class JobNotResumable(Conflict):
code = "JOB_NOT_RESUMABLE"
class PreviewNotFound(NotFound):
code = "PREVIEW_NOT_FOUND"
class PreviewExpired(Conflict):
code = "PREVIEW_EXPIRED"
class PreviewStale(Conflict):
code = "PREVIEW_STALE"
class PreviewLibraryChanged(Conflict):
code = "PREVIEW_LIBRARY_CHANGED"
class UnresolvedCandidates(Conflict):
code = "UNRESOLVED_CANDIDATES"
class PreviewLimitExceeded(BadRequest):
code = "PREVIEW_LIMIT_EXCEEDED"
class PreviewEntryLimitExceeded(BadRequest):
code = "PREVIEW_ENTRY_LIMIT_EXCEEDED"
class RangeParseError(ValueError):
"""``send_file`` could not parse the request's ``Range`` header.
A ``ValueError`` subclass so legacy ``except ValueError`` handlers keep
working; byte-serving routes catch this exact type to answer 416 without
also swallowing unrelated validation errors.
"""
PROBLEM_TYPE_BASE = "https://openbox.local/problems/"
_RETRYABLE_STATUSES = frozenset({408, 425, 429, 500, 502, 503, 504})
_STATUS_TITLES = {
400: "Bad request",
401: "Unauthorized",
403: "Forbidden",
404: "Not found",
405: "Method not allowed",
409: "Conflict",
410: "Gone",
413: "Payload too large",
415: "Unsupported media type",
422: "Unprocessable content",
429: "Too many requests",
500: "Internal error",
503: "Service unavailable",
}
def problem_payload(status, payload, *, request_id="", instance=""):
"""Return an RFC 9457-style problem document built from a legacy error payload.
``code`` is the stable machine-readable key and ``type`` is derived from it,
so both survive rewording. ``error`` duplicates ``detail`` for one major cycle.
"""
title = _STATUS_TITLES.get(status, "Request failed")
detail_value = payload.get("detail")
message = str(payload.get("error") or (detail_value if isinstance(detail_value, str) else "") or title)
code = str(payload.get("code") or f"HTTP_{status}")
body = dict(payload)
body.update({
"type": PROBLEM_TYPE_BASE + code.lower().replace("_", "-"),
"title": title,
"status": status,
"detail": message,
"code": code,
"error": message,
"retryable": status in _RETRYABLE_STATUSES or "retry_after" in payload,
})
if isinstance(detail_value, dict):
body["context"] = detail_value
if request_id:
body["request_id"] = request_id
if instance:
body["instance"] = instance
return body
class HostApiUnsupported(Conflict):
"""A native host sent a major API version this server does not speak."""
code = "HOST_API_UNSUPPORTED"
class BackupFormatUnsupported(BadRequest):
"""A backup manifest declares a format this build cannot read or write (ADR 0093)."""
code = "BACKUP_FORMAT_UNSUPPORTED"
class BackupFormatRetired(ApiError):
"""A backup write requested a format this build no longer writes (ADR 0104)."""
status = 410
code = "BACKUP_FORMAT_RETIRED"
class MovePlanStale(Conflict):
"""The library changed after a move preview; the preview must be run again (ADR 0090)."""
code = "MOVE_PLAN_STALE"