Legibl docs PHP 0.1.0-dev · Python 0.1.0

Troubleshooting

Three failures account for most integration problems. All three are already surfaced honestly by the SDKs; the job of this page is to connect the message you see to the change you make.

First: make the SDK talk

The SDK degrades gracefully by design — it returns your original response rather than raising — which means a broken integration and a clean run look identical from the outside. Before diagnosing anything, attach an observer and log the resolved configuration.

// PHP
error_log(json_encode(\Legibl\Config::fromEnvironment()->describe()));

$observer = new \Legibl\Observer\CallbackObserver(
    function (\Legibl\Outcome $o, ?string $domain, float $secs) {
        error_log(sprintf('legibl %s %s %s', $o->status, $domain, $o->reason ?? ''));
    },
    function (\Legibl\Extraction $c, string $reason) {
        error_log('legibl solve failed: ' . $reason);
    }
);
\Legibl\Interceptor::setInstance(\Legibl\Interceptor::fromConfig(null, $observer));
# Python
print(Config.from_environment().describe())

observer = CallbackObserver(
    lambda outcome, domain, secs: print("legibl", outcome.status, domain, outcome.reason),
    lambda challenge, reason: print("legibl solve failed:", reason),
)

Two fields answer most questions immediately: mode tells you whether you are actually spending, and api_key_set tells you whether a key was found. Neither ever prints a credential.


1. Invalid or missing API key

This has four distinct presentations, and only one of them is loud.

Presentation A — a thrown configuration error

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.

You set LEGIBL_MODE=live explicitly and no key was found.

Fix: set LEGIBL_API_KEY, or remove the explicit mode and let inference pick stub. The usual cause is a key that reaches the web process but not a queue worker, a cron entry, or a container that loads a different env file. Check the process that actually threw, not the one you developed in.

Presentation B — silence, and tokens the target rejects

No key set and no explicit mode means the SDK infers stub mode. It solves nothing, returns the synthetic token legibl-stub-token, resubmits the request with it, and the target rejects it. Nothing throws. Nothing is logged. It looks exactly like a solver with terrible accuracy.

Diagnose: describe() reports "mode": "stub" and "api_key_set": false.

Fix: set LEGIBL_API_KEY. This is the safety default working as intended — an unconfigured install cannot spend money — but it is also the most confusing of the four, so check it first.

Presentation C — stub mode in your test suite despite a key

Both SDKs downgrade to stub when they detect a test run, even with a key present, because a suite that quietly bills a live solver on every build is a common and expensive accident. PHP looks for PHPUNIT_COMPOSER_INSTALL, PEST_RUNNING, or APP_ENV/SYMFONY_ENV equal to test. Python looks for pytest or unittest in sys.modules, PYTEST_CURRENT_TEST, or APP_ENV/ENVIRONMENT equal to test.

Fix, when you genuinely want live solves under test: set LEGIBL_ALLOW_LIVE_IN_TESTS=1. It disables the downgrade and nothing else.

Presentation D — the key is present and wrong

A rejected key comes back inside a normal response envelope, not as a transport failure. The solver returns nothing, the outcome is solve_failed, and your observer's solve-failure callback receives:

createTask failed: ERROR_KEY_DOES_NOT_EXIST

Without an observer this message is discarded and all you see is an unsolved page. That is why the observer comes first on this page.

Fix: re-issue or correct the key. Related codes worth distinguishing: ERROR_ZERO_BALANCE means the key is valid and the account is out of credit — and card-purchased credit is deliberately held briefly before it becomes spendable, so a top-up that has not cleared its hold presents this way.

Never retry an auth failure

ERROR_KEY_DOES_NOT_EXIST is permanent by definition. The gateway keeps the auth/transient boundary intact precisely so clients do not retry it forever; treat it as fatal and alert on it.


2. CURLOPT_RETURNTRANSFER left off

PHP only. This is the single most common integration failure.

What you see

The response body is printed straight to output instead of being returned. curl_exec() hands back true rather than a string. No captcha is ever handled, and the outcome is:

outcome:  unavailable
reason:   CURLOPT_RETURNTRANSFER is off or the body is routed to a callback or
          file, so the response cannot be inspected for a challenge

Why the SDK cannot work around it

With CURLOPT_RETURNTRANSFER off, libcurl writes the body to standard output as it arrives and returns a boolean. There is no body in the process to inspect. The SDK could install its own write callback to capture it — and would then be silently changing where your output goes, which is a worse failure than the one it fixed. So it reports and passes through.

Fix

curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

Set it through Legibl\curl_setopt (or Legibl\curl_setopt_array), not the global one — see partial adoption below.

The same reason string, two other causes

CauseFix
CURLOPT_WRITEFUNCTION is set — the body goes to your callback. Drop the write callback for requests that may carry a challenge, or accept that those requests get no handling.
CURLOPT_FILE is set — the body goes to a stream. Same. File downloads rarely carry captchas; the page that links to them does.

Its close relative: partial adoption

A different reason string, the same unavailable outcome:

the request cannot be reconstructed for resubmission; the handle was
configured through the global curl_setopt rather than Legibl\curl_setopt

You imported curl_exec but not curl_setopt. Resubmitting means rebuilding the request, and libcurl does not let the SDK read most options back — so it records them as you set them. If it never saw them, it will not resubmit a guess.

Fix: import the whole set in that file:

use function Legibl\{curl_init, curl_setopt, curl_setopt_array, curl_exec, curl_close};

Remember this import is required once per file that calls curl. PHP's namespace fallback resolves an unqualified curl_exec() to the global function and never to Legibl\curl_exec — the full explanation is in the PHP quickstart.


3. Challenges detected but unreachable by form injection

The outcome is unhandled: a challenge was found and correctly identified, and it is out of reach. This is a normal, expected outcome, not an error — and reporting it is the entire point, because silently returning the challenge page looks identical to a clean response.

Why it happens

The SDKs solve by form injection: read the challenge parameters out of the server-rendered HTML, obtain a token, write it into the field the page submits, replay the request. That requires two things to exist in the HTML the server sent — the challenge parameters, and a field to write the token into. Four situations break one of them.

ReasonWhat is actually going on
recaptcha script present but no data-sitekey in server HTML; challenge is injected client-side The page loads recaptcha/api.js but the widget is created by JavaScript after load. The sitekey is not in the HTML, so there is nothing to extract.
turnstile script present but no cf-turnstile element in server HTML; widget is rendered explicitly in JavaScript Turnstile's explicit rendering mode deliberately omits the cf-turnstile class and calls turnstile.render() instead. Same problem.
cf-turnstile element present but carries no data-sitekey The element is a placeholder; the sitekey is supplied at render time from script.
data-response-field="false": no hidden token field is created, so the token is consumed in JavaScript The parameters are all there, but Cloudflare creates no hidden input. The token is handed to a JS callback and posted by the application's own code. There is no field to inject into.

A fifth case applies to image captchas: captcha image found but its input field is ambiguous; needs a per-target override. The image was located but the page has several text inputs and none of them looks captcha-related, so pairing them would be a guess.

Which surfaces reach them

SurfaceReaches a server-rendered widgetReaches a JS-rendered widget
PHP Legibl\curl_exec yes no
Python httpx transport / requests adapter yes no
Per-target override + direct createTask yes yes, if a submittable field exists
A JavaScript-executing client (headless browser) yes yes

Legibl does not ship a browser-driven surface, and deliberately so: no SDK in any language carries a browser engine or an inference runtime. If a target genuinely requires executing its JavaScript to produce a submittable token, that is outside what these SDKs do, and this page says so rather than implying otherwise.

How a per-target override resolves it

Everything that broke above is a value detection could not infer from the HTML. If you know that value for a specific target — you read its JavaScript once — you can supply it, and the solve proceeds normally. That is what a per-target override is.

Image captchas: the override is a parameter

Implemented and supported. It short-circuits the heuristic entirely:

// PHP
$extraction = \Legibl\Detect::imageCaptcha($html, [
    'image' => '/captcha/render.php?id=9f2',
    'submit_field' => 'security_code',
]);
# Python
extraction = legibl.extract_image_captcha(html, {
    "image": "/captcha/render.php?id=9f2",
    "submit_field": "security_code",
})

reCAPTCHA and Turnstile: build the extraction yourself

There is no configuration-level override wired up for these in this version (see the gap note below), but the same effect is available directly, because the extraction factories and the solver are both public API. Pin the sitekey and the field name you found in the target's JavaScript:

// PHP — per-target override for a Turnstile widget rendered in JavaScript
use Legibl\{Config, Extraction};
use Legibl\Solver\HttpSolver;

$solver = new HttpSolver(Config::fromEnvironment());

$challenge = Extraction::turnstile(
    '0x4AAAAAAA_sitekey_from_the_targets_js',
    'https://example.test/login',
    'cf-turnstile-response'          // the field the page's own JS posts
);

$solution = $solver->solve($challenge, null);
$fields = $solution !== null ? $solution->asFields() : [];   // ['cf-turnstile-response' => '…']
# Python — the same, for a client-side reCAPTCHA
from legibl.config import Config
from legibl.extraction import Extraction
from legibl.solver import HttpSolver

solver = HttpSolver(Config.from_environment())

challenge = Extraction.recaptcha(
    "6Lc_sitekey_from_the_targets_js",
    "https://example.test/login",
    False,                        # invisible?
    "g-recaptcha-response",
)

solution = solver.solve(challenge, None)
fields = solution.as_fields() if solution else {}

Attach fields to your own request. This works whenever a submittable field exists — including the JS-rendered cases, where the field is created at render time rather than server-side. It does not help when the token is consumed entirely inside JavaScript and never posted, because then there is no field to attach anything to.

Known gap in this version

Config declares an extractionOverrides / extraction_overrides map keyed by target domain — the intended home for registering these per target so the interception path applies them automatically. Nothing reads that map yet. Until it is wired up, overrides work as shown above: a parameter to the image extractor, or a hand-built extraction passed to the solver.

What not to do

Do not treat unhandled as no_challenge

They are different outcomes for a reason. Collapsing them means a target that changes its rendering mode stops working and nothing notices, because every request still reports “fine”. Alert on unhandled by target domain; a type that starts appearing on a target that previously worked is the earliest signal you will get.


Other things worth checking

SymptomLikely cause
Every request reports no_challenge, but the page is plainly a challenge page. The captcha type is not supported — hCaptcha, DataDome, Kasada and PerimeterX are not detected. Or the target refused you before serving the real page at all, which on PHP is often a TLS-fingerprint refusal: see Transport impersonation.
Outcomes are expired. More than 110 seconds elapsed between obtaining the solution and submitting it. Look at your own queueing or retry wrapper, not at the solver.
timed out waiting for a solution. LEGIBL_TIMEOUT is 30 seconds by default and reCAPTCHA v2 p95 latencies run far higher on some backends. Raise it or accept the miss.
An exception from Legibl\curl_multi_exec(). Intentional. The multi-handle API is not wrapped — see the error reference.
Image captchas are never solved automatically. Correct for this version. The SDK detects them but its service client does not build ImageToTextTask — call createTask yourself.