FLUX-511 - Hydrate Oxylabs content that reports no parse_status_code - #11
Merged
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
FLUX-511
1. Impact
Production pec-platform
failed_jobscarries 7,116,382 rows of this single signature, ~104,727/day, failing continuously since 2025-10-24, on queueoxylabs-insights-retrieve(~1% of daily volume, burst pattern):Each failure sits inside
retry(5, …)in pec's retrieve command, so this is roughly 520k wasted Oxylabs result fetches/day. Worse:TypeErroris anError, not anException, so it escaped pec's outercatch (Exception $e)and killed the job beforestoreInsightsDataToBlob()— we have archived zero raw payloads for this cohort since 2025-10-24.2. Mechanism
AmazonProductResult::$contentis declaredAmazonProductResultContent|string. spatie'sDataTypeFactory::inferPropertiesForCombinationType()classifies that union asDataObject(left-to-right$kind ??=), so nested hydration is attempted. It fails insideDataFromArrayResolver::createData()withArgumentCountError→CannotCreateData::constructorMissingParameters, because the promotedint $parse_status_codehad neither a value in the payload nor a default. ThenCastPropertiesDataPipe::cast()swallows it:→
new AmazonProductResult(content: [...])→ the ticket'sTypeError.Root cause:
parse_status_codeis a field of a response the Oxylabs parser actually produced, not of every Oxylabs response. When the parser never ran — bot-check / CAPTCHA / error-page classes, unsupported page types, rawtype=htmlretrievals — Oxylabs omits the key entirely, and the DTO made that payload class unrepresentable. Strictness that cannot reject cleanly is not strictness; it is a crash.3. Fix — and why
parser_typeis part of it, not scope creepparse_status_code→?int(defaultnull) onAmazonProductResultContent,WalmartProductResultContent,GoogleShoppingProductResultContent,GoogleShoppingPricingResultContent,AmazonSellerResultContent.parser_type→?string(defaultnull) onAmazonProductResult,GoogleShoppingProductResult,GoogleShoppingPricingResult— matchingAmazonResult/AmazonPricingResult/WalmartProductResult/eBayResult, which are already?string = null.The second half is load-bearing. Real unparsed Oxylabs responses drop
parser_typetoo (tests/Fixtures/amazon_response_no_parse.jsonandamazon_pricing_png_result.jsonhave no such key). Verified: with only theparse_status_codefix applied, a payload missingparser_typethrowswhich extends
Exception— so unlike today'sTypeErrorit is caught by pec's outercatch (Exception $e)→reattempt()→ nofailed_jobsrow and no blob archive. Fixing onlyparse_status_codewould let afailed_jobs-count-based verification read green while part of the cohort silently converted to invisible retry churn. A flatTypeErrorcount is therefore not sufficient proof of fix (see gates below).Corollary that shaped the fixtures: because the 7.1M prod rows are
Argument #1 ($content) … array given, the outer constructor was reached, so the prod cohort hasparser_typepresent andparse_status_codeabsent. The primary regression fixture reproduces exactly that (parser_type: "");parser_type-absent is covered as a separate payload class by its own test.Zero pec impact from the
parser_typewidening:grep -rn parser_type pec/appreturns nothing.4. Semantics — three states stay three states
Added
ParseStatus::NOT_REPORTED = 0andhasParseStatus(): bool;getParseStatusCode()keeps its exact non-nullableParseStatusEnumsignature.parse_status_codehasParseStatus()getParseStatusCode()12000trueParseStatus::SUCCESS12001trueParseStatus::UNKNOWNnullfalseParseStatus::NOT_REPORTEDNOT_REPORTED = 0sits deliberately outside the vendor's120xxnumbering space: it is our statement about a missing field, never a code Oxylabs sent. It is explicitly not conflated with the vendor's own12007 UNKNOWN.parse_status_codeitself staysnullon the DTO, so the blob archive and thetoJson()round trip stay faithful and reconcilable.success()needed no change on any of the five content DTOs —NOT_REPORTED !== SUCCESS, so it correctly returnsfalse.5. Blast radius: the trait is shared by 6 content DTOs
from()→tryFrom()inTraits/ParseStatusalso affects Universal, Walmart, both Google Shopping and Amazon-seller content. This is a second live bugfix: the enum genuinely skips12001(it has 12000, 12002…12009), soParseStatus::from()was throwingValueError— anError, uncatchable by pec'scatch (Exception)— for any unmodelled code, on all six. Those DTOs now returnParseStatus::UNKNOWNinstead of throwing. Stated here so nobody attributes a future Walmart/Google classification change to the wrong commit.6. Behavioural delta, stated explicitly
The
|stringraw-HTML path is unchanged and pinned by tests:'<html>…'stays a string (test 8),''stays''(test 9), andAmazonProductResultwith string content stays a string (test 10 — the suite had no string-content test for the exact class the prod error names).One real delta: a string containing JSON object/array syntax (
'{}','[]','{"asin":"B01"}') now hydrates into a content object instead of falling through tostring, because every array-shaped payload can now satisfy the content DTO. Oxylabs does not emit this shape fortype: parsed. Test 11 pins it so it is a decision, not a surprise.Also new: loud failure becomes representable data —
{},[],{"unrecognised":"keys"}all now produce a well-formed all-null content object. Consumers must guard onsuccess()/hasParseStatus(), never oninstanceof(now satisfied by an all-null object) and never onempty()(alwaysfalsefor an object). This is documented in the new README section.7. Gates
main)composer test(pest --no-coverage)srcTypeError+CannotCreateData)composer analyse(phpstan)vendor/bin/pint --testThe 4 PHPStan errors are pre-existing baseline drift on
main(3 unmatchedignoreErrorspatterns forsrc/OxylabsApiClient.php+ 1function.alreadyNarrowedTypeinsrc/Traits/Renderable.php) and are intentionally left untouched, so that "analysis-neutral" is verifiable for this change..github/workflows/phpstan.ymlruns on every**.phppush, so that workflow is presumably already red onmain— worth its own PR (follow-up 12).The fix depends on spatie's
DefaultValuesDataPipeinjectingnullfor missing nullable properties beforeCastPropertiesDataPiperuns — behaviour present throughout spatie/laravel-data 4.x, not a pinned patch level. (Verified against 4.17.0;composer.lockis gitignored and CI runscomposer update --prefer-stableon PHP 8.3 / Laravel 11.)Also added
tests/Feature/*.pngto.gitignore: the existing suite writestests/Feature/7350883412053343233.pngand…_walmart.png, which were untracked and un-ignored.8. Scope discipline
Reflection audit of required (non-defaulted, non-nullable) constructor params after this change:
AmazonProductResultContentWalmartProductResultContentGoogleShoppingProductResultContentGoogleShoppingPricingResultContentUniversalResultContentAmazonPricingResultContentAmazonSellerResultContenteBayResultContent9. Release
Tags here are manual and there is no release workflow — merging this PR ships nothing. Green CI is not a fix.
v0.8.8is honest: no BC break (getParseStatusCode(): ParseStatusEnumkeeps its exact signature, the enum case is additive, property types only widen), so pec's existing^0.8.5constraint (locked atv0.8.7) picks it up with no constraint bump.10. Merge / close gates — do not skip
GROUP BYthe prodfailed_jobsexception signature before closing FLUX-511. If the 7.1M rows are not ~100%Argument #1 ($content) … array given, this PR does not close the ticket. Two verified sibling crash classes are not fixed by anything here:content: null→TypeError … null given(spatie skips casting on null, so rawnullreaches the non-nullable union), and a scalar-type mismatch inside content, e.g.{"parse_status_code":12000,"rating":"N/A"}→TypeError: AmazonProductResultContent::__construct(): Argument #12 ($rating) must be of type ?float, string given, thrown inside$context->from()where onlyCannotCreateDatais caught. Note HEAD is literallyFLUX-432 - Type AmazonProductResultContent::$rating as float, not int, so that class has already bitten once. Do not speculatively widen the outer union — get evidence first.failed_jobsand use it verbatim in place of the synthetic fixture. That single step settles gate 1, whetherparser_typewas present, andarray givenvsnull given. Seven million real examples exist.failed_jobsgrowth on theAmazonProductResult::__constructsignature goes flat, and (b) new blobs appear underinsights/oxylabs/amazon_product/<Y/m/d/H>/for the previously-lost cohort. Per §3, (a) alone can read green while the cohort churns invisibly. Secondary signal:oxylabs-insights-retrievequeue latency/CPU should drop as the 5× retry storm disappears.11. Follow-ups — listed, deliberately not implemented here
pec-platform (paired PR, own FLUX ticket, immediately after the SDK release):
composer update always-open/oxylabs-api→v0.8.8. No constraint bump. Merging this SDK PR alone deploys nothing.Insights/RetrieveAmazonProductResults.php:178,RetrieveAmazonProductListingResult.php:253,RetrieveAmazonPriceOffersResult.php:219all do->content->parse_status_code === ParseStatus::NOT_SUPPORTED->valuewith no?->.null === 12003→false, and the precedingempty($response->results[0]->content)guard is nowfalsefor any object, so these payloads fall through toMEASUREMENT_REQUEST_FAULTED+reattempt()— the 7.1Mfailed_jobsrows convert to bounded retry churn, not a clean terminal state. Add one shared helper (e.g.isUnparsedContent(?object $content): bool) and route toMEASUREMENT_CONTENT_NOT_FOUND+failedExternally(). Key it onhasParseStatus()/success(), neverinstanceof, neverempty().validateResult()null-content early return.RetrieveAmazonProductListingResult.php:315callsMarketplaceProduct::validAsin($content->asin);validAsin(string $asin)is non-nullable, and an all-null content passes the precedingasin !== asin_in_urlcheck (null !== null→ false). The call sits in anarray_filterinsidetry { … } finally { … }with nocatch, so it propagates out ofhandle(). Different queue from FLUX-511, so not a blocker for the fire — but it is a new crash the moment a null-status listing payload lands.processAmazonProductResult(386-430) /createOfferData(590) /getBuyboxCondition(711) now receive all-null content objects where they previously never ran. Buybox-suppression keys offprice_buybox == -1plus an empty featured merchant — both trivially true for an empty content object. Reject null-status content before this logic.NOT_REPORTEDis in none of the threein_array($content->getParseStatusCode(), [FAILURE_COULD_NOT_PARSE, NOT_SUPPORTED, FAILURE_PRODUCT_NOT_FOUND])lists (RetrieveAmazonProductListingResult.php:423,RetrieveWalmartProductListingResult.php:264,RetrieveWalmartProductOfferResult.php:477), so a null status will not map toSeller::PRODUCT_NOT_AVAILABLE. Decide deliberately whether it should.parse_status=absentmetric tag. We are about to remove the crash that was accidentally serving as this cohort's alert.RetrieveWalmartProductListingResult.php:120,122,RetrieveWalmartProductOfferResult.php:135,137,RetrieveWalmartProductOfferUrlResult.php:105,RetrieveGoogleShoppingPricingOfferResult.php:140are already?->… ?? nulland already treat non-SUCCESSas faulted.RetrieveWalmartProductOfferResult.php:373constructs content with named argparse_status_code: SUCCESS->value— still valid. There is nomatchonParseStatusanywhere in pec (grepped), so the additive enum case is safe. All 8 pec tests that fabricateparse_status_codepass12000explicitly and keep passing. Re-run pec's PHPStan after the bump:AmazonProductResultContent::$parse_status_codewidensint→?int— safe for===against->value, but will surface if it is ever passed into anintparameter.oxylabs-api SDK (separate ticket):
AmazonPricingResultContent(also needs url, page, title, asin_in_url, review_count),AmazonSellerResultContent(also needs url, query, page_type, description; plusparser_typeonAmazonSellerResult),eBayResultContent(10 more fields; note it cannot take= nullonparse_status_codewithout an optional-before-required deprecation, becauseadditional_propertiesfollows it). Amazon pricing is the highest priority — it shares theoxylabs-insights-retrievequeue viaTYPE_OFFER, andRetrieveAmazonPriceOffersResult.php:219dereferences->content->parse_status_codewith no null-safe operator.AmazonSellerResultandGoogleShoppingProductResulthave non-unioncontent, so malformed payloads there fail loudly withCannotCreateDatainstead of degrading into an opaqueTypeError— a better-behaved symptom, still a crash.#[WithCast]on$contentthat returns strings as strings, callsContent::from()for arrays, and letsCannotCreateDatasurface as a typed SDK exception. spatie'sCombinationTypeswallow will otherwise keep converting any future required-field mismatch into this same opaque constructorTypeError. Out of scope here because it changes the raw-HTML path's behaviour.AmazonSellerResultContent, the#[DataCollectionOf(SellerFeedback::class)]attribute and its@var SellerFeedback[]docblock sit immediately above$parse_status_codeinstead of above$recent_feedback..github/workflows/phpstan.ymlruns on every**.phppush andmainalready carries 4 errors. Fix in its own PR.