| 1 |
<?php |
| 2 |
|
| 3 |
namespace Templately\Utils\Response; |
| 4 |
|
| 5 |
/** |
| 6 |
* The canonical machine-code registry (spec 043 / FR-002). |
| 7 |
* |
| 8 |
* Every downstream error carries one of these codes. The set here MUST be |
| 9 |
* identical to the keys of `TEMPLATELY_ERROR_CODES` in |
| 10 |
* `react-src/utils/errors/errorCodes.ts` — a cross-language sync test fails on |
| 11 |
* any divergence (spec 043 US4). When you add a code, add it in BOTH files in |
| 12 |
* the same change. |
| 13 |
* |
| 14 |
* Source of truth for the table: `specs/043-core-api-response-contract/contracts/error-codes.md`. |
| 15 |
*/ |
| 16 |
class ErrorCode { |
| 17 |
// Auth / account. |
| 18 |
const AUTH_EXPIRED = 'templately_auth_expired'; |
| 19 |
const NOT_VERIFIED = 'templately_not_verified'; |
| 20 |
const ACCOUNT_DISABLED = 'templately_account_disabled'; |
| 21 |
const SITE_DISCONNECTED = 'templately_site_disconnected'; |
| 22 |
const SITE_URL_REQUIRED = 'templately_site_url_required'; |
| 23 |
const AGENT_KEY_NOT_ALLOWED = 'templately_agent_key_not_allowed'; |
| 24 |
const LIMIT_REACHED = 'templately_limit_reached'; |
| 25 |
const INVALID_API_KEY = 'templately_invalid_api_key'; |
| 26 |
const AUTH_STATE_INVALID = 'templately_auth_state_invalid'; |
| 27 |
const AUTH_MISSING_API_KEY = 'templately_auth_missing_api_key'; |
| 28 |
const AUTH_PROVIDER_FAILED = 'templately_auth_provider_failed'; |
| 29 |
|
| 30 |
// Transport / server. |
| 31 |
const NETWORK_ERROR = 'templately_network_error'; |
| 32 |
const NETWORK_OFFLINE = 'templately_network_offline'; |
| 33 |
const TIMEOUT = 'templately_timeout'; |
| 34 |
const SERVER_ERROR = 'templately_server_error'; |
| 35 |
// A PHP fatal on THIS site (shutdown-captured) — unlike SERVER_ERROR (the |
| 36 |
// Templately cloud), never retryable: the same request dies the same way. |
| 37 |
const FATAL_ERROR = 'templately_fatal_error'; |
| 38 |
const SERVER_HTML_RESPONSE = 'templately_server_html_response'; |
| 39 |
const EMPTY_RESPONSE = 'templately_empty_response'; |
| 40 |
const MALFORMED_JSON = 'templately_malformed_json'; |
| 41 |
const MALFORMED_QUERY = 'templately_malformed_query'; |
| 42 |
const RATE_LIMITED = 'templately_rate_limited'; |
| 43 |
|
| 44 |
// Request / client. |
| 45 |
const INVALID_NONCE = 'templately_invalid_nonce'; |
| 46 |
const FORBIDDEN = 'templately_forbidden'; |
| 47 |
const INVALID_REQUEST = 'templately_invalid_request'; |
| 48 |
const VALIDATION_FAILED = 'templately_validation_failed'; |
| 49 |
const NOT_FOUND = 'templately_not_found'; |
| 50 |
|
| 51 |
// Domain. |
| 52 |
const SITE_LIMIT_REACHED = 'templately_site_limit_reached'; |
| 53 |
const NO_CREDITS = 'templately_no_credits'; |
| 54 |
const CLOUD_SPACE_FULL = 'templately_cloud_space_full'; |
| 55 |
const PACK_NOT_FOUND = 'templately_pack_not_found'; |
| 56 |
const UPDATE_REQUIRED = 'templately_update_required'; |
| 57 |
const ALREADY_SUBMITTED = 'templately_already_submitted'; |
| 58 |
|
| 59 |
// AI generation (spec 026 taxonomy folded in; `terminal === ! retryable`). |
| 60 |
const AI_PENDING = 'templately_ai_pending'; |
| 61 |
const AI_NOT_READY = 'templately_ai_not_ready'; |
| 62 |
const AI_INVALID_PROCESS = 'templately_ai_invalid_process'; |
| 63 |
const AI_UNAUTHORIZED = 'templately_ai_unauthorized'; |
| 64 |
const AI_REMOTE_FAILED = 'templately_ai_remote_failed'; |
| 65 |
const AI_EXPIRED = 'templately_ai_expired'; |
| 66 |
const AI_INTERNAL_ERROR = 'templately_ai_internal_error'; |
| 67 |
|
| 68 |
// Expected cancellation (FR-009a) — `info`, never surfaced to the user. |
| 69 |
const CANCELLED = 'templately_cancelled'; |
| 70 |
|
| 71 |
/** |
| 72 |
* severity + default retryable per code. |
| 73 |
* |
| 74 |
* severity: fatal | error | warning | info — drives the surface (modal / |
| 75 |
* sticky notice / auto-dismiss notice / silent). |
| 76 |
*/ |
| 77 |
private static $meta = [ |
| 78 |
self::AUTH_EXPIRED => [ 'severity' => 'error', 'retryable' => false, 'status' => 401 ], |
| 79 |
self::NOT_VERIFIED => [ 'severity' => 'warning', 'retryable' => false, 'status' => 403 ], |
| 80 |
self::ACCOUNT_DISABLED => [ 'severity' => 'error', 'retryable' => false, 'status' => 403 ], |
| 81 |
self::SITE_DISCONNECTED => [ 'severity' => 'error', 'retryable' => false, 'status' => 403 ], |
| 82 |
self::SITE_URL_REQUIRED => [ 'severity' => 'fatal', 'retryable' => false, 'status' => 404 ], |
| 83 |
self::AGENT_KEY_NOT_ALLOWED => [ 'severity' => 'error', 'retryable' => false, 'status' => 403 ], |
| 84 |
self::LIMIT_REACHED => [ 'severity' => 'error', 'retryable' => false, 'status' => 409 ], |
| 85 |
self::INVALID_API_KEY => [ 'severity' => 'error', 'retryable' => false, 'status' => 401 ], |
| 86 |
self::AUTH_STATE_INVALID => [ 'severity' => 'error', 'retryable' => true, 'status' => 400 ], |
| 87 |
self::AUTH_MISSING_API_KEY => [ 'severity' => 'error', 'retryable' => true, 'status' => 400 ], |
| 88 |
self::AUTH_PROVIDER_FAILED => [ 'severity' => 'error', 'retryable' => true, 'status' => 502 ], |
| 89 |
|
| 90 |
self::NETWORK_ERROR => [ 'severity' => 'error', 'retryable' => true, 'status' => 0 ], |
| 91 |
self::NETWORK_OFFLINE => [ 'severity' => 'warning', 'retryable' => true, 'status' => 0 ], |
| 92 |
self::TIMEOUT => [ 'severity' => 'error', 'retryable' => true, 'status' => 0 ], |
| 93 |
self::SERVER_ERROR => [ 'severity' => 'error', 'retryable' => true, 'status' => 500 ], |
| 94 |
self::FATAL_ERROR => [ 'severity' => 'fatal', 'retryable' => false, 'status' => 500 ], |
| 95 |
self::SERVER_HTML_RESPONSE => [ 'severity' => 'error', 'retryable' => true, 'status' => 502 ], |
| 96 |
self::EMPTY_RESPONSE => [ 'severity' => 'error', 'retryable' => true, 'status' => 502 ], |
| 97 |
self::MALFORMED_JSON => [ 'severity' => 'error', 'retryable' => false, 'status' => 502 ], |
| 98 |
self::MALFORMED_QUERY => [ 'severity' => 'fatal', 'retryable' => false, 'status' => 500 ], |
| 99 |
self::RATE_LIMITED => [ 'severity' => 'warning', 'retryable' => true, 'status' => 429 ], |
| 100 |
|
| 101 |
// Retryable: a nonce expires with the page, and the client's existing |
| 102 |
// `retry_again` path reloads assets and repeats the call — which succeeds. |
| 103 |
self::INVALID_NONCE => [ 'severity' => 'error', 'retryable' => true, 'status' => 403 ], |
| 104 |
self::FORBIDDEN => [ 'severity' => 'error', 'retryable' => false, 'status' => 403 ], |
| 105 |
self::INVALID_REQUEST => [ 'severity' => 'error', 'retryable' => false, 'status' => 400 ], |
| 106 |
self::VALIDATION_FAILED => [ 'severity' => 'error', 'retryable' => false, 'status' => 422 ], |
| 107 |
self::NOT_FOUND => [ 'severity' => 'error', 'retryable' => false, 'status' => 404 ], |
| 108 |
|
| 109 |
self::SITE_LIMIT_REACHED => [ 'severity' => 'error', 'retryable' => false, 'status' => 403 ], |
| 110 |
self::NO_CREDITS => [ 'severity' => 'warning', 'retryable' => false, 'status' => 402 ], |
| 111 |
self::CLOUD_SPACE_FULL => [ 'severity' => 'error', 'retryable' => false, 'status' => 507 ], |
| 112 |
self::PACK_NOT_FOUND => [ 'severity' => 'error', 'retryable' => false, 'status' => 404 ], |
| 113 |
self::UPDATE_REQUIRED => [ 'severity' => 'error', 'retryable' => false, 'status' => 426 ], |
| 114 |
self::ALREADY_SUBMITTED => [ 'severity' => 'info', 'retryable' => false, 'status' => 200 ], |
| 115 |
|
| 116 |
self::AI_PENDING => [ 'severity' => 'info', 'retryable' => true, 'status' => 202 ], |
| 117 |
self::AI_NOT_READY => [ 'severity' => 'info', 'retryable' => true, 'status' => 202 ], |
| 118 |
self::AI_INVALID_PROCESS => [ 'severity' => 'error', 'retryable' => false, 'status' => 400 ], |
| 119 |
self::AI_UNAUTHORIZED => [ 'severity' => 'error', 'retryable' => false, 'status' => 401 ], |
| 120 |
self::AI_REMOTE_FAILED => [ 'severity' => 'error', 'retryable' => false, 'status' => 502 ], |
| 121 |
self::AI_EXPIRED => [ 'severity' => 'error', 'retryable' => false, 'status' => 410 ], |
| 122 |
// Retryable: a transient manifest/session race produced this code and a |
| 123 |
// single occurrence was killing a paid, still-running generation. The |
| 124 |
// client poll is attempt-capped, so retrying a permanent internal error |
| 125 |
// is bounded, while a transient one now recovers. |
| 126 |
self::AI_INTERNAL_ERROR => [ 'severity' => 'error', 'retryable' => true, 'status' => 500 ], |
| 127 |
|
| 128 |
self::CANCELLED => [ 'severity' => 'info', 'retryable' => false, 'status' => 0 ], |
| 129 |
]; |
| 130 |
|
| 131 |
/** |
| 132 |
* Every registered code, in declaration order. |
| 133 |
* |
| 134 |
* @return string[] |
| 135 |
*/ |
| 136 |
public static function all() { |
| 137 |
return array_keys( self::$meta ); |
| 138 |
} |
| 139 |
|
| 140 |
/** |
| 141 |
* Whether $code is a member of the registry. |
| 142 |
* |
| 143 |
* @param string $code |
| 144 |
* @return bool |
| 145 |
*/ |
| 146 |
public static function exists( $code ) { |
| 147 |
return is_string( $code ) && isset( self::$meta[ $code ] ); |
| 148 |
} |
| 149 |
|
| 150 |
/** |
| 151 |
* severity / retryable / status defaults for a code. |
| 152 |
* |
| 153 |
* Unknown codes degrade to a generic error rather than throwing — the |
| 154 |
* normalizer must never fail on an unexpected upstream value (FR-006). |
| 155 |
* |
| 156 |
* @param string $code |
| 157 |
* @return array{severity:string,retryable:bool,status:int} |
| 158 |
*/ |
| 159 |
public static function meta( $code ) { |
| 160 |
if ( self::exists( $code ) ) { |
| 161 |
return self::$meta[ $code ]; |
| 162 |
} |
| 163 |
|
| 164 |
return [ 'severity' => 'error', 'retryable' => false, 'status' => 500 ]; |
| 165 |
} |
| 166 |
|
| 167 |
/** |
| 168 |
* @param string $code |
| 169 |
* @return string one of fatal|error|warning|info |
| 170 |
*/ |
| 171 |
public static function severity( $code ) { |
| 172 |
$meta = self::meta( $code ); |
| 173 |
return $meta['severity']; |
| 174 |
} |
| 175 |
|
| 176 |
/** |
| 177 |
* @param string $code |
| 178 |
* @return bool |
| 179 |
*/ |
| 180 |
public static function retryable( $code ) { |
| 181 |
$meta = self::meta( $code ); |
| 182 |
return $meta['retryable']; |
| 183 |
} |
| 184 |
|
| 185 |
/** |
| 186 |
* The default HTTP-equivalent status for a code. |
| 187 |
* |
| 188 |
* @param string $code |
| 189 |
* @return int |
| 190 |
*/ |
| 191 |
public static function status( $code ) { |
| 192 |
$meta = self::meta( $code ); |
| 193 |
return $meta['status']; |
| 194 |
} |
| 195 |
|
| 196 |
/** |
| 197 |
* The default user-facing sentence for a code. |
| 198 |
* |
| 199 |
* Lives here, not in the normalizer, because more than one producer needs it: |
| 200 |
* the normalizer when upstream sent no usable message, and |
| 201 |
* `TemplatelyException::get_user_message()` when the thrown error carries only |
| 202 |
* an internal one. Two copies of this wording is two things to keep in step. |
| 203 |
* |
| 204 |
* Deliberately generic — a message the user can act on, never a description of |
| 205 |
* what went wrong internally (FR-008). |
| 206 |
* |
| 207 |
* @param string $code |
| 208 |
* @return string |
| 209 |
*/ |
| 210 |
public static function default_message( $code ) { |
| 211 |
switch ( $code ) { |
| 212 |
case self::AUTH_EXPIRED: |
| 213 |
case self::INVALID_API_KEY: |
| 214 |
return __( 'Your session has expired. Please log in again.', 'templately' ); |
| 215 |
case self::NOT_VERIFIED: |
| 216 |
return __( 'Please verify your email address to continue.', 'templately' ); |
| 217 |
case self::ACCOUNT_DISABLED: |
| 218 |
return __( 'This account is not active. Please contact support.', 'templately' ); |
| 219 |
case self::SITE_DISCONNECTED: |
| 220 |
return __( 'This site is not connected to Templately. Please log in again.', 'templately' ); |
| 221 |
case self::SITE_URL_REQUIRED: |
| 222 |
return __( 'Templately could not identify this site. Please contact support.', 'templately' ); |
| 223 |
case self::LIMIT_REACHED: |
| 224 |
return __( 'You have reached your plan limit.', 'templately' ); |
| 225 |
case self::NOT_FOUND: |
| 226 |
case self::PACK_NOT_FOUND: |
| 227 |
return __( 'The requested resource was not found.', 'templately' ); |
| 228 |
case self::RATE_LIMITED: |
| 229 |
return __( 'Too many requests. Please wait a moment and try again.', 'templately' ); |
| 230 |
case self::VALIDATION_FAILED: |
| 231 |
return __( 'Validation failed.', 'templately' ); |
| 232 |
case self::NETWORK_OFFLINE: |
| 233 |
return __( 'You appear to be offline. Check your connection and try again.', 'templately' ); |
| 234 |
case self::NETWORK_ERROR: |
| 235 |
return __( 'Could not reach the Templately server. Please check your connection and try again.', 'templately' ); |
| 236 |
case self::TIMEOUT: |
| 237 |
return __( 'The request timed out. Please try again.', 'templately' ); |
| 238 |
case self::MALFORMED_QUERY: |
| 239 |
return __( 'Templately could not complete this request due to an internal error. Please update the plugin or contact support.', 'templately' ); |
| 240 |
case self::ALREADY_SUBMITTED: |
| 241 |
return __( 'This has already been submitted. Thank you!', 'templately' ); |
| 242 |
case self::CANCELLED: |
| 243 |
return __( 'Request cancelled.', 'templately' ); |
| 244 |
case self::INVALID_NONCE: |
| 245 |
return __( 'This page has expired. Please refresh and try again.', 'templately' ); |
| 246 |
case self::FORBIDDEN: |
| 247 |
return __( 'You do not have permission to do this.', 'templately' ); |
| 248 |
case self::SERVER_ERROR: |
| 249 |
return __( 'Something went wrong on the Templately server. Please try again in a moment.', 'templately' ); |
| 250 |
case self::FATAL_ERROR: |
| 251 |
return __( 'A critical error occurred on your website while processing this request. Details were saved to the Templately log — please contact support if it keeps happening.', 'templately' ); |
| 252 |
default: |
| 253 |
return __( 'The request could not be completed.', 'templately' ); |
| 254 |
} |
| 255 |
} |
| 256 |
|
| 257 |
/** |
| 258 |
* spec 026's AI generation slug → this registry (043 FR-014). |
| 259 |
* |
| 260 |
* The AI endpoints shipped their own four-field envelope |
| 261 |
* (`{success, terminal, code, message}`) before this contract existed, and |
| 262 |
* the poller keys off `terminal` only. Folding it in ADDITIVELY — rather |
| 263 |
* than rewriting it — keeps that poller byte-stable while giving the AI |
| 264 |
* failures the same machine codes as everything else. |
| 265 |
* |
| 266 |
* The two taxonomies line up exactly: `terminal === ! retryable`. Only |
| 267 |
* `pending`/`not_ready` are retryable, and only those two are non-terminal. |
| 268 |
* `ai_terminal_matches_retryable()` asserts that rather than assuming it. |
| 269 |
*/ |
| 270 |
private static $ai_code_map = [ |
| 271 |
'pending' => self::AI_PENDING, |
| 272 |
'not_ready' => self::AI_NOT_READY, |
| 273 |
'invalid_process' => self::AI_INVALID_PROCESS, |
| 274 |
'unauthorized' => self::AI_UNAUTHORIZED, |
| 275 |
'remote_failed' => self::AI_REMOTE_FAILED, |
| 276 |
'expired' => self::AI_EXPIRED, |
| 277 |
'internal_error' => self::AI_INTERNAL_ERROR, |
| 278 |
]; |
| 279 |
|
| 280 |
/** |
| 281 |
* @param string $ai_code One of spec 026's taxonomy slugs. |
| 282 |
* @return string|null the registry code, or null for `ok` (a success, not an error). |
| 283 |
*/ |
| 284 |
public static function from_ai_code( $ai_code ) { |
| 285 |
return isset( self::$ai_code_map[ $ai_code ] ) ? self::$ai_code_map[ $ai_code ] : null; |
| 286 |
} |
| 287 |
|
| 288 |
/** |
| 289 |
* Every AI slug this registry knows about. |
| 290 |
* |
| 291 |
* @return string[] |
| 292 |
*/ |
| 293 |
public static function ai_codes() { |
| 294 |
return array_keys( self::$ai_code_map ); |
| 295 |
} |
| 296 |
} |
| 297 |
|