Errors & outcomes
Three things can go wrong and they are kept apart on purpose: a thrown error means the SDK is unusable as configured; an outcome records what happened to one request; an API error code comes back from the service. Every one of them is listed here with its corrective action.
Typed errors — PHP
All three extend \RuntimeException, so one catch
(\Legibl\Exception\LegiblException $e) clause catches everything the SDK
raises and nothing else.
Legibl\Exception\ConfigurationException
The SDK is not usable as configured. Raised at first use rather than at some arbitrary later point, and the message always names the fix. There are four sites that raise it.
- Unknown Legibl mode "…". Expected one of: live, stub, replay, dry_run.
- Fix:
LEGIBL_MODEholds a value outside that set — often a typo or a shell variable that expanded to empty plus something else. Set it to one of the four, or unset it and let the mode be inferred. - Legibl is in live mode but no API key is set. Set the LEGIBL_API_KEY environment variable, or use stub mode for local development.
- Fix: you forced
LEGIBL_MODE=livewithoutLEGIBL_API_KEY. Set the key, or drop the explicit mode so the SDK falls back to stub. Common in containers where the key is injected into the app but not into a worker or cron process. - Legibl is in replay mode but no fixture corpus is configured. Set the LEGIBL_FIXTURES environment variable to the corpus directory.
- Fix: point
LEGIBL_FIXTURESat the corpus directory, or stop forcingLEGIBL_MODE=replay. - HttpSolver requires an API key. Set LEGIBL_API_KEY, or use stub mode.
- Fix: you constructed
HttpSolverdirectly with aConfigthat carries no key. Build the interceptor throughInterceptor::fromConfig()instead, which selects a solver the mode actually supports.
Legibl\Exception\LegiblException
The base class, and raised directly in exactly one place:
Legibl\curl_multi_exec().
Legibl does not wrap the curl multi-handle API, so challenge handling would be
silently inactive for these requests. Use the global \curl_multi_exec()
explicitly to acknowledge that, or route these requests through Legibl\curl_exec().
Fix: either call \curl_multi_exec() by its
qualified global name — which acknowledges that those requests get no challenge
handling — or move them onto Legibl\curl_exec(). The function
refuses rather than forwarding silently, because a silent forward leaves the SDK
looking installed while every challenge on those requests passes unhandled.
Legibl\Exception\SolveFailedException
The class exists so that callers can distinguish a failed solve from a
configuration problem, but no code path in PHP SDK 0.1.0-dev throws
it. A challenge that cannot be solved is reported as the
solve_failed outcome and the original response is returned. Do
not write a catch block expecting it to fire; read the outcome
instead.
Typed errors — Python
All three subclass RuntimeError, so except
legibl.errors.LegiblError catches everything the SDK raises.
| Error | Raised when | Corrective action |
|---|---|---|
ConfigurationError |
Config(...) with a mode outside
live | stub | replay | dry_run. |
Correct LEGIBL_MODE, or unset it and let the mode be
inferred. |
ConfigurationError |
Mode live with no API key. |
Set LEGIBL_API_KEY, or stop forcing live mode. |
ConfigurationError |
Mode replay with no fixture path. |
Set LEGIBL_FIXTURES to the corpus directory. |
ConfigurationError |
HttpSolver(config) constructed with a keyless config. |
Use Interceptor.from_config(), which picks a solver the
mode supports. |
LegiblError |
Base class. Nothing raises it directly in 0.1.0. | Catch it to catch all SDK errors at once. |
SolveFailedError |
Never, in 0.1.0. A failed solve is the
solve_failed outcome, not an exception. |
Read the outcome; do not depend on this being raised. |
ImportError |
LegiblTransport() constructed without httpx installed.
Message: httpx is not installed. Install it with: pip install
'legibl[httpx]' |
Install the extra, or use LegiblAdapter with requests
instead. |
NotImplementedError |
A custom Solver subclass that does not override
solve(). |
Implement solve(challenge, page_url) -> Optional[Solution]. |
Outcomes
Every intercepted request produces exactly one outcome, delivered to your observer with the target domain and the elapsed time. These are the errors most customers will actually see, because the SDK's governing rule is graceful degradation: it returns your original response rather than raising.
| Outcome | What it means | Corrective action |
|---|---|---|
no_challenge |
No challenge the SDK recognises was found. | Usually nothing. If you expected a challenge, confirm the type is supported — an unsupported system produces this outcome too. |
solved |
A solution was obtained and the request resubmitted once with it attached. | None. Note that “solved” means the token was submitted, not
that the target accepted it; report acceptance back with
reportCorrect /
reportIncorrect. |
unhandled |
A challenge was identified but is out of reach of form injection.
Carries the captcha type and a reason. |
Read the reason and see unreachable challenges. This is a normal, expected outcome — not a bug. |
unavailable |
The SDK could not act for reasons of its own: the body could not be inspected, or the request could not be reconstructed. | Both causes are integration-side and both are fixable — see the fixes. |
solve_failed |
The service did not return a usable solution: an error envelope, a transport failure, a solution with no token, or a timeout. | Attach an observer and read the solveFailed message —
it carries the service's errorCode. Match it against the
error-code table. |
expired |
A solution was obtained but exceeded the 110-second freshness window
before it could be submitted. Carries solutionAge /
solution_age. |
Look at your own latency between the solve and the resubmission — queueing, a slow retry wrapper, a debugger pause. Recorded separately so it is never charged to the solver's accuracy. |
passthrough |
PHP only. The request failed, or its body went somewhere the SDK cannot see, so curl's own return value was handed straight back. | Check curl_errno() / curl_error() on the
handle. This is a transport failure, not a captcha failure. |
Exact reason strings
The reason field is prose intended for a human reading a log.
These are the strings this version emits.
| Outcome | Reason | Meaning |
|---|---|---|
unavailable |
CURLOPT_RETURNTRANSFER is off or the body is routed to a callback or file, so the response cannot be inspected for a challenge | Fix |
unavailable |
the request cannot be reconstructed for resubmission; the handle was configured through the global curl_setopt rather than Legibl\curl_setopt | Fix |
unavailable |
the request cannot be reconstructed for resubmission | Python: the integration reported that this request cannot be replayed. |
unhandled |
recaptcha script present but no data-sitekey in server HTML; challenge is injected client-side | Fix |
unhandled |
turnstile script present but no cf-turnstile element in server HTML; widget is rendered explicitly in JavaScript | Fix |
unhandled |
cf-turnstile element present but carries no data-sitekey | Fix |
unhandled |
data-response-field="false": no hidden token field is created, so the token is consumed in JavaScript | Fix |
unhandled |
captcha image found but its input field is ambiguous; needs a per-target override | Fix |
unhandled |
no captcha-like image found | Returned by the image extractor when the page has no image that looks like a captcha. |
expired |
solution expired before resubmission | See expired above. |
passthrough |
response not returned as a string | curl returned false or a bool; a transport failure. |
Reason strings are prose and are not contracted across languages — PHP and
Python can currently explain the same unhandled challenge with different
wording, and no test compares them. Switch on status and
captchaType, which are protocol values; read reason
with your eyes.
Solve-failure messages
When a solve does not happen, the solver calls
solveFailed / solve_failed on your observer
with one of four messages, suffixed with the service's errorCode
when the response carried one.
| Message | Corrective action |
|---|---|
createTask failed: ERROR_KEY_DOES_NOT_EXIST |
The key is wrong, revoked, or absent. See invalid or missing API key. |
createTask failed: ERROR_ZERO_BALANCE |
Top up. Note that card-purchased credit is held briefly before it becomes spendable. |
createTask failed: ERROR_TASK_NOT_SUPPORTED |
The captcha type is not one of the four supported types. |
createTask failed (no code) |
The service was unreachable, timed out, returned a non-2xx status, or
returned something that was not JSON. Check network egress and
LEGIBL_SERVICE_URL. |
getTaskResult failed: … |
The task was accepted and then failed terminally — most often
ERROR_CAPTCHA_UNSOLVABLE or ERROR_TIMEOUT.
Retry the request; a repeated pattern on one target is worth
reporting. |
solution carried no usable token |
The result was ready but carried none of
gRecaptchaResponse, token or text.
This is a service-side defect — report it. |
timed out waiting for a solution |
Polling exceeded LEGIBL_TIMEOUT (default 30s). Raise the
timeout for reCAPTCHA v2 on a slow backend, or accept the miss. Measured
p95 latencies for reCAPTCHA v2 run from 20s to 168s depending on the
backend, so 30s does drop real traffic. |
Service error codes
The full errorId / errorCode envelope and
its code table are on the HTTP API page.