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(), …):
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:
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 cryptoBy donating, you agree to our donation terms.
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.