` of * additional diagnostic fields. §10.2 specifically calls out the * `PolicyViolation` case as a `reason: "policy_violation"` field * inside this map rather than a separate exception class, and * §12.3 calls out a `retry_after`-shaped field for rate-limit * refusals. Keys MUST be non-empty strings; values are opaque to * the base because the rendering / observability layers each * consume their own subset. * * Inheritance contract: * * - Concrete subclasses MUST extend this base. * - Concrete subclasses SHOULD expose a stable per-class error * code constant (`AuthenticationException::ERROR_CODE`, etc.) * that they pass to the parent constructor. This keeps the * §10.2 table 1:1 with the type system and lets call sites * match on the constant rather than the string. * - The base extends {@see \RuntimeException} (not * {@see \LogicException}) because every code in §10.2 represents * an externally-induced failure mode (network, credentials, * upstream contract drift) that is unavoidable at runtime, not a * programming error the static type system could have caught. * * `provider-api-contracts.md` §10.1 forbids: * * - Collapsing every failure into a bare `RuntimeException`. * - Returning empty arrays / null to mask a failure. * - Including secret material in the exception message. * * The third rule binds every consumer of {@see self::context()}: the * map MUST NOT carry credentials, signing keys, or unredacted * authorization headers. Provider implementations are responsible * for redacting before populating the context. * * @package Affilun\Domain\Provider\Exception */ declare(strict_types=1); namespace Affilun\Domain\Provider\Exception; use InvalidArgumentException; use RuntimeException; use Throwable; /** * Abstract base class shared by every provider-layer exception. * * See the file-level docblock for the mapping to * `provider-api-contracts.md` §10, the per-field validation rules, * and the inheritance contract concrete subclasses must honor. */ abstract class ProviderException extends RuntimeException { /** * Stable §10.2 error code carried by this exception. * * Forwarded by concrete subclasses through their `ERROR_CODE` * constant; held verbatim on the base so call sites that catch * at the abstract type can still consult the code without * resorting to `instanceof` chains. */ private string $errorCode; /** * Stable provider key per §4 identifying which provider * implementation raised this exception. */ private string $provider; /** * Structured diagnostic context per §10 / §12.3. * * @var array */ private array $context; /** * Construct a provider exception with explicit values for every * cross-cutting field. * * The constructor is exhaustive so a programming error in a * concrete subclass (forgetting to pass the error code, * mismatching the provider key against the actual call site) * surfaces at the type system boundary rather than at the * observability layer. * * Validation: * * - `errorCode` is trimmed and rejected when empty after * trimming. The base does NOT verify it against the §10.2 * closed set because subclasses are the canonical source of * truth for the per-class code; verifying here would either * duplicate the table or force a circular dependency. * * - `provider` is trimmed and rejected when empty after * trimming. * * - `message` is forwarded to {@see RuntimeException::__construct()} * verbatim (no trimming, no validation) because the §10.1 * "no secret material in the exception message" rule binds * the caller, not this base — silently mutating the message * would only obscure a programming error. * * - `context` keys MUST be non-empty strings; values are * opaque (mixed). Non-string keys raise * {@see InvalidArgumentException} so the JSON-encoding * contract on the §10 error envelope stays stable. * * @param string $errorCode Stable §10.2 error code. * @param string $provider §4 provider key. * @param string $message Human-readable, non-secret summary. * @param array $context Structured diagnostic context. * @param Throwable|null $previous Underlying cause, or null. * * @throws InvalidArgumentException When `errorCode` or `provider` * is empty after trimming, or * when `context` carries a * non-string key. */ public function __construct( string $errorCode, string $provider, string $message = '', array $context = array(), ?Throwable $previous = null ) { $normalizedErrorCode = trim( $errorCode ); if ( $normalizedErrorCode === '' ) { $invalid = 'ProviderException requires a non-empty error code per provider-api-contracts.md §10.2.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $invalid ); } $normalizedProvider = trim( $provider ); if ( $normalizedProvider === '' ) { $invalid = 'ProviderException requires a non-empty provider key per provider-api-contracts.md §4.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $invalid ); } $this->errorCode = $normalizedErrorCode; $this->provider = $normalizedProvider; $this->context = self::normalizeContext( $context ); parent::__construct( $message, 0, $previous ); } /** * Stable §10.2 error code carried by this exception. * * Used by REST error envelopes (per the §10 error contract), * log lines, and admin diagnostics views to render a stable * machine-readable identifier for the failure mode. */ public function errorCode(): string { return $this->errorCode; } /** * Stable provider key per §4 identifying which provider * implementation raised this exception. */ public function provider(): string { return $this->provider; } /** * Structured diagnostic context per §10 / §12.3. * * Keys are non-empty strings; values are opaque. Consuming * surfaces SHOULD treat the map as a documentation-defined * key-value bag (canonical fields include `reason`, `request_id`, * `retry_after`, `response_digest`) rather than as a free-form * payload. * * Per §10.1 the map MUST NOT carry secret material; provider * implementations are responsible for redacting before * populating it. * * @return array */ public function context(): array { return $this->context; } /** * Validate the `context` map's keys. * * Values are NOT validated because §10 expressly preserves * provider-native data here for downstream observability; * the rendering / log layers each consume their own subset, so * shape constraints on values would only obstruct legitimate * use. * * @param array $context Raw context map. * * @return array * * @throws InvalidArgumentException When any key is not a * non-empty string. */ private static function normalizeContext( array $context ): array { $normalized = array(); foreach ( $context as $key => $value ) { if ( ! is_string( $key ) ) { $message = sprintf( 'ProviderException context keys must be strings; got %s.', gettype( $key ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $trimmedKey = trim( $key ); if ( $trimmedKey === '' ) { $message = 'ProviderException context keys must be non-empty strings.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized[ $trimmedKey ] = $value; } return $normalized; } }