feat(apiclient): RFC 9457 Problem Details on v2 error responses#687
Merged
Conversation
43fc834 to
3805aeb
Compare
0ab1547 to
d9727a8
Compare
This was referenced May 13, 2026
3805aeb to
7cac102
Compare
7 tasks
d9727a8 to
ff645f7
Compare
7cac102 to
492a6fe
Compare
ff645f7 to
83ea2ca
Compare
492a6fe to
e7d0917
Compare
83ea2ca to
2dc5663
Compare
e7d0917 to
797cc89
Compare
2dc5663 to
4bc874c
Compare
797cc89 to
6b3b300
Compare
4bc874c to
3807b5e
Compare
KarlG-nbis
reviewed
May 26, 2026
6b3b300 to
5d384e0
Compare
e7afd82 to
9e5eabc
Compare
Adds the RFC 9457 ProblemDetails shape, a typed APIError exposing StatusCode so callers can errors.As on it instead of substring-matching error messages, and parseErrorResponse which converts a non-2xx *http.Response into *APIError. Successful Problem Details bodies format as '<Title> (<status>): <Detail>'; status-only or type-only bodies still populate apiErr.Problem and fall through to a body-sample message. Non-Problem bodies (HTML, plain text, empty, malformed JSON) fall back to 'server returned status N: <truncated-body>'. Content-Type matching goes through mime.ParseMediaType so it honours RFC 7231's case-insensitivity rule. The body read is capped at 8 KiB (errorBodyReadLimit) to bound allocation from a hostile or misconfigured server. Wiring into V2Client is in the next commit.
getJSON and DownloadFile non-2xx paths now delegate to parseErrorResponse,
replacing the inline 'server returned status N: {body}' formatting that
getJSON previously used. Server errors now surface as 'Title (status):
Detail' for Problem-compliant responses.
DownloadFile's resolve-step 403 flatten switches from substring-matching
on 'status 403' to errors.As against the typed *APIError. Substring
matching silently stopped working once the server returned Problem
Details (the formatted message contains 'Forbidden (403)', never
'status 403'); the typed check is the spec-correct way to discriminate
on status code. Both the resolve-step 403 and the download-GET 403
still flatten to 'dataset/file does not exist or access denied:
<UserArg>' to preserve the server's existence-leakage contract.
…ated] Appends " [truncated]" suffix when the response body exceeds maxErrorBodyBytes so the user knows the output was cut off. Addresses KarlG-nbis review feedback on #687.
9e5eabc to
25fc9bd
Compare
KarlG-nbis
approved these changes
May 27, 2026
nanjiangshu
reviewed
May 27, 2026
nanjiangshu
reviewed
May 27, 2026
nanjiangshu
reviewed
May 27, 2026
nanjiangshu
reviewed
May 27, 2026
Contributor
nanjiangshu
left a comment
There was a problem hiding this comment.
Nice work. Added a minor comment regarding potential problems when truncating byte strings.
Back up to the last valid rune boundary before slicing so we never split a multi-byte character. Keeps the byte-based limit for terminal output sizing. Addresses nanjiangshu review feedback on #687.
nanjiangshu
approved these changes
May 27, 2026
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.
Related issue(s) and PR(s)
Closes #678. Stacks on #686. Fifth of five PRs for #663.
Description
Replaces
V2Client's ad-hoc"server returned status N: {body}"error format with RFC 9457 Problem Details parsing, adds a typedAPIErrorwithStatusCodeforerrors.As-based callers, and drops the substring-match hack inV2Client.DownloadFile's 403-flatten logic. Small PR (+205 / −13), focused on the error surface; success path unchanged.Key design points
apiclient.APIErroris a typed error withStatusCode(so callers canerrors.As), an optional*ProblemDetailspopulated when the body parses, and a truncatedBodyfor diagnostics. Themsgfield stays unexported so the formatted message always goes throughError().parseErrorResponsetries Problem Details when Content-Type isapplication/problem+jsonorapplication/json(case-insensitive viamime.ParseMediaType). Falls back to"server returned status N: <truncated>"for HTML / plain / empty / malformed. Body read capped at 8 KiB.V2Client.DownloadFile's resolve-step 403 flatten moves fromstrings.Contains(err.Error(), "status 403")toerrors.As(err, &apiErr) && apiErr.StatusCode == http.StatusForbidden. The old substring match silently stopped firing the moment the server started returning Problem Details — the new message contains"Forbidden (403)", never"status 403"— so the 403 would have leaked. This PR is whatgetJSON's// #678 replaces error wrapping...comment was waiting for."dataset/file does not exist or access denied: <UserArg>"; the typed*APIErrormessage is discarded on that path so the leakage contract can't leak Problem Details detail.User-visible CLI message change (worth flagging at review)
Non-403 v2 server responses change shape. Dev stack returns
application/problem+json, so:Error: failed to get datasets, reason: server returned status 401: {"detail":"Missing, invalid, or expired bearer token.",...}Error: failed to get datasets, reason: Unauthorized (401): Missing, invalid, or expired bearer token.403 flattening is unchanged. Shell scripts or log filters keyed on the literal
"server returned status N"need updating for the Problem-Details path.Out of scope (tracked separately)
v2File(deferred Codex finding)."invalid base URL"shape for empty/malformedBaseURLon v2.datasetCase/recursiveCase(carried from feat(download): v2 file download via /files/{fileId} #686).How to test
Local:
go build ./...,go vet ./...,gofmt -l .: clean.go test ./...: 191 pass.golangci-lint run --timeout 5m: 0 issues.Integration (build tag
integration, against the live v2 dev stack):TestV2_ListDatasets_Smoke,TestV2_ListFiles_Smoke,TestV2_ListFiles_ExactPath_Smoke,TestV2_ListFiles_PathPrefix_NoMatch,TestV2_DatasetInfo_Smoke,TestV2_DownloadFile_EndToEnd,TestV2_DownloadFile_NotFound403: 7/7 pass.Manual against the live dev stack:
list --api-version v2 --dataset EGAD_FORBIDDEN→"Forbidden (403): access denied"(typed; list has no leakage flatten).list --api-version v2 --datasetswith fake-signature JWT →"Unauthorized (401): Missing, invalid, or expired bearer token."download --api-version v2 --dataset-id EGAD_DOES_NOT_EXIST --pubkey <pem> some-file.c4gh→"dataset/file does not exist or access denied: some-file.c4gh"(403 on list-resolve via the typederrors.Aspath — would have leaked under the old substring hack).Test plan
go test ./...: 191 passgo vet ./...: cleangolangci-lint run: cleangofmt -l .: cleanerrors.As, happy path unchanged