REST API 3.0: the version from the call URL, nested errors - #21
Merged
Conversation
The SDK could not call a single REST 3.0 method: buildURL appended .json unconditionally, and v3 answers 404 for the suffix. It now appends it only for v1, choosing the version by the /rest/api/ segment of the base URL. The URL is the only source of truth on purpose. A caller has to pass a v3 URL anyway — without /api/ the portal runs the v1 method of that name — so an option would be a second place to state the version, and could disagree with the first. Errors: v3 nests code and message in an object where v1 has a flat string. The parser now picks the shape by the JSON TYPE of "error", not by the version of the URL, because a v3 URL answers in BOTH: the REST gateway in front of the v3 controller reports in the flat v1 form, and QUERY_LIMIT_EXCEEDED — which the retry loop depends on — comes from that gateway. APIError also carries the per-field Validation array, the only actionable part of a v3 validation error. One v3 code is folded onto its v1 sentinel so errors.Is keeps working: METHODNOTFOUNDEXCEPTION -> ERROR_METHOD_NOT_FOUND. The rest are not, because measurement says they are not the same sets — v3 answers a wrong webhook token with ACCESSDENIEDEXCEPTION where v1 answers INVALID_CREDENTIALS. CodeOf keeps returning the code that arrived. Pages, Scan and CallBatch refuse a v3 client rather than half-work. v3 ignores start and sends no next, so Pages read the first page of tasks.task.list on a live portal and reported a finished walk with Err() == nil: 2 rows out of 423. The v3 batch is a different protocol — commands at the top level, a positional array back, no per-command errors — so Batch/Ref/Halt have nothing to map onto. Both sentinels name what to call instead. v1 behavior is unchanged, and has its own regression tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md # batch.go
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.
Not a single REST 3.0 method worked through the SDK. There was one cause:
buildURLappended.jsonunconditionally, and v3 does not accept that suffix.Measured on a live portal:
What was done
The version is derived from the URL. If the base URL has a
/rest/api/segment, this is v3, and.jsonis not appended. There is no new public option for it and none is proposed: the caller has to pass a v3 URL in any case, otherwise the portal will run the method of the old version — that is, the URL already states the version, and a second source of truth could be made to disagree with it (say "version 3" and forget/api/, and every call goes to v1 while being decoded by the rules of v3). The version is computed fromBaseURLon every call rather than remembered on the client, so that aBaseURLswapped after creation does not leave a stale flag behind.v1 behaviour has not changed in any respect, and there are regression tests for that:
TestV1BaseURLKeepsTheJSONSuffix,TestV1ErrorTaxonomyIsUnchanged,TestPagesStillWorksOnV1.Parsing v3 errors. On v3 the code and the text sit in a nested object rather than flat.
*APIErroris filled in from both shapes, anderrors.Is,CodeOfand the whole taxonomy work as before.The shape is chosen by the type of the
errorfield, not by the version of the URL — because a v3 URL answers with both. Measured:…/rest/api/…/tasks.task.getwith{"id":"abc"}→ HTTP 500 and a flat{"error":"INTERNAL_SERVER_ERROR","error_description":"Internal server error"}; this is reported by the REST gateway that sits in front of the v3 controller. Parsing by version would have lost the code of every such error, includingQUERY_LIMIT_EXCEEDED, which is what retries rest on.v3 codes are not translated, and exactly one is folded.
errors.Is(err, ErrMethodNotFound)fires onBITRIX_REST_V3_EXCEPTION_METHODNOTFOUNDEXCEPTIONas well — the same situation, the same meaning. The rest are not folded, and that is not an omission:…_ACCESSDENIEDEXCEPTIONlooks likeACCESS_DENIED, but v3 answers with it on a wrong webhook token too, where v1 answersINVALID_CREDENTIALS. One v3 code covers two v1 codes; folding them would make the branch "the rights are wrong, the credentials are fine" fire on dead credentials.BITRIX_REST_V3_EXCEPTION_prefix is not universal:crm.deal.timeline.activity.email.liston a badidanswersCRM_EMAIL_INVALID_REQUESTin the same envelope. Any "strip the prefix and map it" scheme is built on a false premise.So
CodeOfreturns the code as it arrived (it goes into a log), and for the cases that cannot be folded there areErrV3Validation,ErrV3EntityNotFound,ErrV3AccessDeniedand theCodeV3*constants.APIError.Validation— the fields v3 rejected the request over. The code and the text of all such errors are equally generic, so the list of fields is the only part that says what exactly is wrong.Pages/ScanandCallBatch/CallBatchChunkedrefuse to work on v3 —ErrV3WalkUnsupported,ErrV3BatchUnsupported, before the request is sent.This is not caution, it is preventing a silent loss of data. v3 has no cursor:
startis silently ignored,nextandtotalare absent. On a live portalPagesovertasks.task.listread the first page, saw nonextand reported a finished walk withErr() == nil— 2 rows out of 423. A partial export that looks like a complete one is exactly what a walk must never have.v3 does have a
batchmethod, but it is a different protocol: the commands go in the root of the body as{"method": …, "query": {…}}, the reply is an array in submission order (the keys are discarded), and the first failing command aborts the whole request instead of producingresult_error.Batch,Ref,Halt,BatchResulthave nothing to map onto. The refusal replaces the portal's own answer to a v1 batch body —…INVALIDSELECTEXCEPTION, «Не удается распознать выражение select», for a request that has noselectat all. Both sentinels name what to use instead.Verified on a live portal
Everything below is a cloud portal's answer, taken with calls made by the SDK itself.
.jsonon v3{"result":{"total":19},"time":{…}}— the same as v1's;UnwrapworksCRM_EMAIL_INVALID_REQUESTwith no prefix among them) + a flatINTERNAL_SERVER_ERRORon a v3 URLerrors.Is(ErrMethodNotFound)trueon the v3 code;CodeOf— the v3 code as it arrivedValidation[{Field:id Message:Обязательное поле \id` не указано}]`nextnortotal;startis ignored; a v1 filter is rejectedbatchon v3Core.Call, the reply is[{"total":19},{"items":[…]}]documentationCallit givesResult == nilwith no error — there is no envelope, which is why the docs say to fetch it with a plainhttp.Getprofile,batch,ERROR_METHOD_NOT_FOUND— unchangedNot verified: OAuth authorization on v3 and
CallMultiparton v3 (the run went over a webhook; v3 declares a JSON body only). The documentation calls this untested rather than working.Local checks
go build ./...,go vet ./...,gofmt -l .(empty),go test -race ./...— all green locally. Checked locally because the repository's CI runs were hanging inqueuedand did not start; on this PR CI did run after all and is green (stableandgo.mod (minimum)).Merge order
The branch is off
main. It merges cleanly with #20. After merging #19 → #20 → #17 → #18 there remain two trivial "both added next to each other" conflicts, both resolved by keeping both sides:batch.go— two adjacent paragraphs in the comment onCallBatch(the batch duration from Batch: say what it costs and how long it takes #19 and v3 from here);CHANGELOG.md— my### Changedsection at the end of[0.2.0]and theBatchResult.IDsitem from BatchResult.IDs: read the identifiers out of a batch without losing their positions #18.🤖 Generated with Claude Code