Skip to content

[csharp][restsharp] throwOnAnyError: wrap oneOf/anyOf FromJson failures in ApiException - #25455

Open
ybai08 wants to merge 2 commits into
OpenAPITools:masterfrom
ybai08:fix/25218-csharp-throwonanyerror-oneof
Open

ybai08 wants to merge 2 commits into
OpenAPITools:masterfrom
ybai08:fix/25218-csharp-throwonanyerror-oneof

Conversation

@ybai08

@ybai08 ybai08 commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #25218.

With throwOnAnyError=true, the restsharp ApiClient turns a recorded ErrorException into an ApiException (#25217). The oneOf/anyOf branch after that check calls FromJson itself, and when that throws, the catch rethrew the raw inner exception. So a response that RestSharp did not try to deserialize, but that matches none of the schemas, reached the caller as a bare InvalidDataException with no status code, body or headers.

Under throwOnAnyError, the catch now throws ApiException(status, "Error calling <resource>: <message>", content, headers, inner), the same shape as the ErrorException guard just above it, with the FromJson error kept as InnerException. Without throwOnAnyError the generated code is unchanged. The change is in both restsharp templates, ApiClient.mustache and the useIntForTimeout fallback ApiClient.v790.mustache.

What I measured, on a generated net10.0 restsharp client for a spec whose GET /pet returns oneOf: [Cat, Dog] (both required + additionalProperties: false), against a local HttpListener returning 200:

Response master this PR
text/plain, body OK raw System.IO.InvalidDataException ("The JSON string OK cannot be deserialized into any schema defined.") ApiException ErrorCode=200, ErrorContent=OK, headers kept, InnerException InvalidDataException
application/json, body {"swims":true} ApiException(200) (inner ApiException) same
text/html, an HTML page ApiException(200) (inner InvalidOperationException) same

So for a JSON body the #25217 guard already fires, because RestSharp's own deserialization fails first and sets ErrorException. The gap is a response RestSharp leaves alone, such as text/plain. Same results with useIntForTimeout=true (the v790 template).

A 4xx/5xx response whose body matches none of the schemas is left to ExceptionFactory, as throwOnAnyError already does for other operations: the wrap applies only below 400 (since 3db0ecf, after review), so a custom factory is not bypassed. Before this PR such a response got the raw InvalidDataException.

Tests

  • CSharpClientCodegenTest.testThrowOnAnyErrorWrapsOneOfDeserializationFailure: generates a restsharp client for each combination of throwOnAnyError and useIntForTimeout, and checks ApiClient.cs has the wrapping throw new ApiException(...) only when throwOnAnyError is on (and the old rethrow otherwise). Fails on master templates (does not contain line [throw new ApiException(...)]). It also fails when only ApiClient.mustache is fixed, which covers the v790 template.
  • ./mvnw test -pl modules/openapi-generator -am -Dtest=CSharpClientCodegenTest: 27 tests, 0 failures.
  • ./mvnw test -pl modules/openapi-generator -am (JDK 21, macOS arm64): 5574 tests, 0 failures, 0 errors, 11 skipped.
  • Regenerated with ./bin/generate-samples.sh bin/configs/csharp-restsharp-net4.7-multipleFrameworks.yaml (the only config with throwOnAnyError). One file changes: Client/ApiClient.cs, +2/-1. Built the regenerated library with dotnet build -f netstandard2.1 (.NET SDK 10.0.401, macOS arm64): succeeded. Its net47/net7.0 test project wasn't run here (no Mono, no .NET 7 runtime), and ThrowOnAnyErrorTests doesn't cover this path because the petstore spec has no oneOf model.

PR checklist

  • Read the contribution guidelines.
  • Run ./mvnw clean package, ./bin/generate-samples.sh ./bin/configs/*.yaml and ./bin/utils/export_docs_generators.sh. Instead: ./mvnw package -pl modules/openapi-generator-cli -am -DskipTests, the modules/openapi-generator tests, and ./bin/generate-samples.sh bin/configs/csharp-restsharp-net4.7-multipleFrameworks.yaml. The change sits inside {{#throwOnAnyError}} / {{^throwOnAnyError}}, and the other C# configs don't set that option, so their output is unchanged. No option or doc changed.
  • C# technical committee: @mandrean @shibayan @Blackclaws @lucamazzanti

Summary by cubic

Fixes #25218 so throwOnAnyError wraps oneOf/anyOf deserialization failures in an ApiException for responses below 400, while leaving 4xx/5xx to ExceptionFactory as before.

  • Previously a bare InvalidDataException (no status code, body, or headers) surfaced when RestSharp did not deserialize the body (e.g., text/plain).
  • Applies the same wrapping as the existing ErrorException guard, keeping the parse error as InnerException, in both ApiClient.mustache and the useIntForTimeout fallback ApiClient.v790.mustache.
  • 4xx/5xx unmatched bodies still reach ExceptionFactory, so custom factories aren't bypassed.
  • Generated output unchanged when throwOnAnyError is off.
  • Adds a template test covering both flag combinations across both templates.

Written for commit 3db0ecf. Summary will update on new commits.

View guided diff

…es in ApiException

When RestSharp does not deserialize the body itself (for example a
text/plain response), ErrorException stays null and the oneOf/anyOf
FromJson call is the first to fail. Its InvalidDataException reached the
caller without status code, body or headers, although throwOnAnyError
promises an ApiException. Wrap it like the ErrorException case, with the
parse error as the inner exception, in both restsharp ApiClient templates.

Fixes OpenAPITools#25218

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

@wing328

wing328 commented Nov 1, 2026

Copy link
Copy Markdown
Member

@ybai08 thanks for the PR

can you please review the feedback from cubic-dev-ai?

@wing328 wing328 modified the milestones: 7.27.0, 7.28.0 Nov 2, 2026
…es to ExceptionFactory

The oneOf/anyOf FromJson wrap threw ApiException for every status code,
so a 4xx/5xx body matching none of the schemas never reached
ExceptionFactory and a custom factory was ignored for those operations.
Wrap only status codes below 400, like the ErrorException check above it,
and let 4xx/5xx fall through to ExceptionFactory as other operations do.
@ybai08

ybai08 commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

@wing328 thanks, the cubic finding is valid. Fixed in 3db0ecf.

Under throwOnAnyError, the guard just above already skips status >= 400 so those responses reach ExceptionFactory in the generated API method (api.mustache, the this.ExceptionFactory("{{operationId}}", localVarResponse) call after this.Client.Get<...>). My oneOf/anyOf catch threw ApiException for every status, so a 4xx/5xx body matching none of the schemas never got there. A custom factory was skipped for those operations only. (On master the raw InvalidDataException skipped it too, so this was older than the PR, but the PR should not keep it.)

The catch now wraps only status < 400. For 4xx/5xx the parse error is dropped, Data stays default, and the response goes to ExceptionFactory like any other operation. Same change in ApiClient.mustache and ApiClient.v790.mustache. This replaces the 4xx/5xx paragraph in the PR description: with the default factory those responses now get DefaultExceptionFactory's ApiException(status, body, headers), without the parse error as InnerException.

What I ran:

  • A generated net10.0 restsharp client (throwOnAnyError=true) for GET /pet returning oneOf: [Cat, Dog], against a local HttpListener, with a custom ExceptionFactory that counts calls:
    • 500 text/plain / 500 application/json {"swims":true}: before, ApiException(500) thrown by ApiClient, factory called 0 times. Now the custom factory's exception, factory called once. (Master template: raw InvalidDataException, 0 calls.)
    • 500 with the default factory: ApiException ErrorCode=500, body and headers kept.
    • 200 text/plain OK: still ApiException ErrorCode=200 with inner InvalidDataException. 200 matching Cat still returns Cat.
    • Same results with useIntForTimeout=true (v790 template).
  • CSharpClientCodegenTest.testThrowOnAnyErrorWrapsOneOfDeserializationFailure now asserts the < 400 guard around the wrap. It fails on the previous templates, and also when only the v790 template is left unfixed.
  • ./mvnw test -pl modules/openapi-generator -am -Dtest=CSharpClientCodegenTest: 27 tests, 0 failures (JDK 11).
  • ./bin/generate-samples.sh bin/configs/csharp-restsharp-net4.7-multipleFrameworks.yaml: one file changes, Client/ApiClient.cs. dotnet build -f netstandard2.1 of that library succeeded (.NET SDK 10.0.401).

Not run: the full modules/openapi-generator test suite for this commit, the other sample configs (none sets throwOnAnyError), and the sample's net47/net7.0 test project.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG][csharp][restsharp] throwOnAnyError: oneOf/anyOf deserialization failure on 2xx escapes as InvalidDataException instead of ApiException

2 participants