---
title: "Verdicts and exceptions — Safe Fetch for Laravel | Roundly"
description: "The Reason enum, the exception hierarchy, retryable() and blockedBy(), neutral messages that never name the URL, and the named constructors."
url: https://roundly-consulting.com/open-source/docs/safe-fetch-for-laravel/errors
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Safe Fetch for Laravel](https://roundly-consulting.com/open-source/docs/safe-fetch-for-laravel.md)

# Verdicts and exceptions

## Reasons

Enums\\Reason is string-backed. Reason::retryable() returns the column below; for status it returns false and the exception decides:

| Value | Thrown as | retryable() | Meaning |
| --- | --- | --- | --- |
| `invalid_url` | `DestinationRejected` | — | Not a URL the guard can parse one way only (a control character, no authority, a backslash). |
| `scheme` | `DestinationRejected` | — | Not http(s), or a scheme the policy does not allow (https only by default). |
| `credentials` | `DestinationRejected` | — | User info (user:pw@) in the authority. |
| `non_canonical_host` | `DestinationRejected` | — | A host spelled in a form parsers disagree on (numeric IPv4, %, a zone id, whitespace). |
| `port` | `DestinationRejected` | — | Outside 1–65535, or a port the policy does not allow. |
| `blocked` | `DestinationRejected` | — | A private, loopback, metadata or blocked destination — blockedBy() names the rule. |
| `unresolved` | `DestinationUnavailable` | yes | The host name does not resolve. DNS can heal. |
| `not_allowed` | `DestinationRejected` | — | A strict host allowlist does not list the host. |
| `cannot_pin` | `DestinationRejected` | — | The connection could not be pinned to the vetted addresses (no curl, or it went elsewhere). |
| `too_many_redirects` | `DestinationRejected` | — | More redirects than the policy allows. |
| `redirect_downgrade` | `DestinationRejected` | — | A redirect from https to http. |
| `timeout` | `DestinationUnavailable · FetchFailed` | yes | The time budget ran out — between hops, or curl 28 in fetch() / download(). |
| `status` | `FetchFailed` | 408, 429, 5xx | fetch() or download() got a non-2xx answer; status() and retryAfter() on the exception. |
| `too_large` | `FetchFailed` | — | A body larger than the byte cap. |
| `connection` | `FetchFailed` | yes | The connection failed or broke off. |
| `tls` | `FetchFailed` | — | The server’s TLS certificate could not be verified. |
| `protocol` | `FetchFailed` | — | The answer was not readable HTTP (a status outside 100–599, a broken head). |

The enum carries the shared helpers of enums-for-laravel (values(), names(), options(), label(), is(), isIn(), fromName(), validationRule(), …):

```php
use RoundlyConsulting\SafeFetch\Enums\Reason;

Reason::values()->all();               // ['invalid_url', 'scheme', 'credentials', …]
Reason::Unresolved->retryable();       // true
Reason::from($row->reason)->isIn([Reason::Blocked, Reason::NotAllowed]);
```

## Exceptions

```
RuntimeException
└── SafeFetchException (abstract)      reason(): Reason · retryable(): bool · url(): string · host(): string
    ├── DestinationRejected            + blockedBy(): ?string
    ├── DestinationUnavailable
    └── FetchFailed                    + status(): ?int · retryAfter(): ?int
LogicException
└── UnsafeRequestOptions               a programmer error; names the option
```

- url() returns the URL as the caller handed it in; host() the canonical host the verdict is about (empty when the URL had none the guard could read). Neither is in the message.
- FetchFailed::retryAfter() is the server’s Retry-After in seconds (seconds or an HTTP date, capped at 86 400), only for a retryable status.
- UnsafeRequestOptions is thrown for an option that would take a request around the guard (see Guarded requests). Its message names the option, in English only — a programmer error, like Laravel’s own exceptions.

## Messages that give nothing away

Messages are fixed per reason (safe-fetch::messages.<reason>, English and Slovak) and never contain the URL or an address. blocked and unresolved read the same — “The URL cannot be fetched: its destination is not allowed or cannot be reached.” — so the message is safe to show the person who submitted the URL: telling “private” from “not found” would map your internal network for whoever controls the URL.

reason(), retryable() and blockedBy() do tell them apart — they are for your code and your operators. Never let that difference reach the person who submitted the URL: a “try again later” shown only when retryable() is the same oracle again. Retry in the background, show one message.

## blockedBy()

DestinationRejected::blockedBy() — operators only — names the rule behind a blocked verdict:

| Rule | Meaning |
| --- | --- |
| `hostname` | localhost or \*.localhost, not listed as a private host. |
| `blocked_host` | A host in hosts.blocked / blockHosts() — refused before any lookup. |
| `app_host` | One of the app’s own hosts, or an address literal that is one of its addresses, while the block is on. |
| `blocked_range` | An address in ranges.blocked / blockRanges(). |
| `private_address` | An address in the blocked-network table, neither in an allowed range nor (unless it is cloud metadata) the address of a private host. |

## Named constructors

The guard builds its exceptions through these; they are public, so a test double or a wrapper of your own can throw exactly what the package throws. $url is a #\[SensitiveParameter\] and never in the message:

```php
DestinationRejected::because(Reason::Blocked, $url, 'internal.example', blockedBy: 'private_address');
DestinationUnavailable::unresolved($url, 'gone.example');                 // retryable
DestinationUnavailable::timedOut($url, 'slow.example');                   // retryable
FetchFailed::forStatus($url, 'api.example', 503, retryAfter: 30);         // status() 503, retryAfter() 30
FetchFailed::because(Reason::Connection, $url, 'api.example');            // retryable
UnsafeRequestOptions::option('proxy', 'a proxy resolves the host name itself.');
```

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/safe-fetch-for-laravel.md)
