TrustScreen is a KYC document-fraud pre-screen I built. A Laravel 13 app receives batches of up to 10 ID documents or selfies, sends each one to a Python forensics microservice, and turns the per-layer scores that come back into a risk score and a recommendation. The service measures; Laravel decides.
That split puts one HTTP call at the centre of every verification. And the most dangerous line in that call isn't the URL or the payload. It's ->retry().
Retries feel like free reliability. Add ->retry(3, 100) and transient failures go away. But a retry is a claim: "if I ask again, the answer might be different." For some failures that's true. For others it's false, and acting on it costs money, time and, in a fraud tool, the honesty of the result.
Here's how I decided which failures get retried, who retries them, and why the caller's timeout sits above the service's own.
Two kinds of failure
When Laravel calls the forensics service, a request can fail in two very different ways.
The network failed. The service was restarting, a connection was refused, DNS hiccuped. The item never got analysed, so nothing about the document is known. Asking again a moment later has a real chance of working.
The service answered, and the answer was a failure. The classifier ran out of time, the file couldn't be decoded, the payload was too large. The service did its job: it looked at this specific item and reported what happened. That's a verdict about the item, not a blip.
Retrying the second kind is where the cost hides. The AI-generation classifier is a paid inference call. Retrying a classifier failure spends another call to get, most of the time, the same answer. And a file that couldn't be decoded the first time won't decode the second time.
So the rule is: retry the network, never the verdict.
The retry, in code
Laravel's HTTP client takes a when callback that decides, per exception, whether to try again. That callback is where the rule lives:
$response = $this->http ->baseUrl((string) $config['base_url']) ->timeout((float) $config['timeout']) ->connectTimeout((float) $config['connect_timeout']) ->retry( (int) $config['retry_times'], (int) $config['retry_sleep_ms'], fn (\Exception $e): bool => $e instanceof ConnectionException, throw: false, ) ->asJson() ->post('/v1/forensics/analyze', $payload); Three things are doing the work here.
The when callback only says yes to ConnectionException. When the service returns an error response, Laravel hands the callback a RequestException, the callback says no, and there's no second attempt.
throw: false means a failed response comes back as a response instead of an exception. That matters, because the error body carries a contract error code and I want to keep it.
The numbers come from config, not from the call site:
'forensics' => [ 'base_url' => env('FORENSICS_BASE_URL', 'http://localhost:8001'), 'timeout' => (float) env('FORENSICS_TIMEOUT', 30.0), 'connect_timeout' => (float) env('FORENSICS_CONNECT_TIMEOUT', 5.0), 'retry_times' => (int) env('FORENSICS_RETRY_TIMES', 2), 'retry_sleep_ms' => (int) env('FORENSICS_RETRY_SLEEP_MS', 200), ], In Laravel the first argument to retry() is the total number of attempts, so 2 means one retry, 200 ms after the first failure. A connection that can't be opened within 5 seconds counts as down. The numbers are small on purpose: the batch API is synchronous, so every retry is time a caller spends waiting with the request held open.
Keep the cause the service reported
When the service answers with a failure, the client reads the error code from the body and turns it into a typed exception:
if ($response->failed()) { $body = $response->json(); throw ForensicsException::fromResponse( $body['error_code'] ?? null, $body['error_message'] ?? null, ); } fromResponse maps the string onto an ErrorCode enum. Per-item codes pass through from the microservice unchanged, so the reviewer sees the same cause the pipeline saw: CLASSIFIER_TIMEOUT, DECODE_FAILED, PAYLOAD_TOO_LARGE. A code the app doesn't recognise becomes INTERNAL_ERROR instead of crashing the batch.
A connection that still fails after its retry becomes FORENSICS_UNREACHABLE. Whatever the kind of failure, it ends up as an exception that carries a code.
The orchestrator catches it per item. A failing document gets status error, no score and no recommendation, while the rest of the batch is still scored. An item that couldn't be checked must never look like one that was checked and passed. A failure that quietly defaulted to "forward" would be a fraud tool telling a lie.
Machine retries and human retries
Not retrying automatically doesn't make a failure final. It moves the retry decision to the person who can judge it.
The ErrorCode enum carries that decision:
public function isRetryable(): bool { return match ($this) { self::ClassifierTimeout, self::ClassifierUnavailable, self::ForensicsUnreachable, self::SourceUnreachable, self::InternalError => true, default => false, }; } The HTTP client never calls this method. It exists for the reviewer. A classifier timeout might clear later, so they can re-submit the item. A file that couldn't be decoded, is too large or is the wrong type won't get better by trying again: the reviewer needs a new file from the customer, so it's marked not retryable.
That gives two retry policies, on purpose:
| Failure | Who retries | When |
|---|---|---|
| Transport failure | The HTTP client, automatically | Once, after 200 ms |
| Retryable item error | A human reviewer | When they choose to re-submit |
| Non-retryable item error | Nobody | The input has to change |
The machine only retries what it can judge without context. Everything else goes to a person, with a reason attached.
Why the caller's timeout is 30 seconds
The last decision is the easiest to get wrong. It's one line of config with a comment above it:
// Must exceed the microservice's own classifier timeout, or Laravel // gives up while the service is still working and reports a misleading // cause. The service budgets 10s for the classifier alone. 'timeout' => (float) env('FORENSICS_TIMEOUT', 30.0), The forensics service gives the classifier 10 seconds. If the classifier runs over, the service reports CLASSIFIER_TIMEOUT, which is an accurate account of what went wrong.
Now imagine the caller's timeout were shorter than that budget, say 8 seconds. On a slow item, Laravel would give up first. Laravel's HTTP client reports its own timeout as a ConnectionException, the same type as a refused connection. So three things go wrong at once:
- The reviewer sees "the analysis service could not be reached", when the service was up and working.
- The
whencallback sees aConnectionExceptionand retries, sending the same slow item through the paid classifier again. - The service may well finish the first request anyway, so that work is paid for and thrown away.
Setting the caller's timeout well above the service's budget keeps the inner timeout in charge. The service reports what happened, and only a real network failure triggers a retry. The rule I follow: the timeout closest to the work should fire first. Each layer further out gets a bigger budget than the layer inside it.
The generous outer timeout has a cost. If the service hangs completely, a caller waits 30 seconds for that item. I accepted that, because a hung service is rare and loud, while a misreported cause is quiet and erodes trust in every result.
What I'd tell my past self
- Write down what a retry claims. "Asking again might give a different answer" is true for a dropped connection and false for a verdict.
- Put the retry rule in the
whencallback, next to the call it governs, not in a catch block somewhere else. - Keep machine retries and human retries apart. One is a policy; the other is information you give a reviewer.
- Nest your timeouts: smaller budgets inside, larger ones outside, so the layer that knows the cause is the one that reports it.
- Make the numbers config. Timeouts and retry counts are environment variables here, so tuning them needs no deploy.
None of this is exotic. It's about a dozen lines of code and a few comments. But those lines decide whether a slow model costs one call or two, and whether a reviewer sees the real cause or a misleading one.
How do you split retries between your HTTP client and the people using your system?
Originally published on my blog.
