Migrating to C# generator v3
Version 3.0.0 of the C# SDK generator includes changes the compiler doesn’t catch: code that still builds but behaves differently, and changes that aren’t visible in your code at all. Which changes affect your SDK depends on your API. Release the regenerated SDK as a new major version, and share this page with your SDK users so they can update their code.
Before you upgrade
Generator versions 3.0.0 and later read configuration from sdk-config.yml instead of generators.yml. Run fern sdk migrate to create sdk-config.yml from your existing configuration.
Pass credentials by name
How credentials are passed changes for several auth styles, and the order of constructor parameters may change. A positional call still compiles, but the value can bind to a different credential and fail at request time with an authentication error. All credential parameters are optional, so the compiler doesn’t flag the change.
Common changes include:
You may also see these changes:
- An OAuth flow the SDK can’t run itself, such as authorization code, becomes a bearer
tokenargument that the caller supplies. - A secondary credential, such as an account token, moves from the constructor to a field on each request.
- A per-request API key is set on each request object instead of on the client.
- A less common constructor argument, such as a trace ID header, is removed. Set it through
ClientOptions.AdditionalHeadersinstead:
To fix: Pass every credential argument by name, and check client construction against the new constructor signature. Pass clientOptions: by name as well.
Keep existing type names with configuration
If the regenerated SDK renames the root client, base exceptions, or environment class, set the previous name in sdk-config.yml instead of updating call sites. Most keys are renamed from their generators.yml spellings, so setting the old key in sdk-config.yml has no effect.
The package and generation.naming blocks go under the C# entry in targets:
To fix: Set any renamed classes back to their previous names. To keep existing using directives valid, set package.namespace to your current root namespace. package.packageName only sets the NuGet package ID, and defaults to the namespace if you leave it unset.
Connection and timeout errors have their own exception types
A connection failure throws ApiConnectionException instead of the raw HttpRequestException. A request timeout throws ApiTimeoutException instead of TaskCanceledException, so timeouts are distinguishable from caller cancellations. Existing catch blocks still compile but no longer catch these errors.
The new exception types are in the <RootNamespace>.Http.Exceptions namespace. The base API exception (<Sdk>ApiException) and per-status exceptions such as NotFoundError keep their names and namespaces.
To fix: Replace catch (HttpRequestException) and catch (TaskCanceledException) blocks that handled connection failures or timeouts.
Enums accept unknown values
If an enum was a plain C# enum, it becomes a struct that wraps a string. Most SDKs already use this shape, so nothing changes for them.
Equality comparisons such as plant.Status == PlantStatus.Available still work. A switch over the enum doesn’t compile, because the members are static readonly fields instead of constants (error CS9135). Switch on .Value and the generated Values constants instead:
Previously, an unknown value was converted to the first enum member, so an API that returned "reserved" produced Available. The SDK preserves the value as-is, so plant.Status.Value is "reserved" and reaches the default branch.
To fix: Rewrite switch statements to use .Value and the Values constants, and add a default branch for values added to the API after the SDK was generated.
Some exception bodies are no longer typed
Most typed error bodies are unchanged. If an exception exposes a named error model, such as ErrorResponse, ex.Body keeps that type. In two cases, Body becomes object:
- The body is a primitive. A status whose body schema is a scalar, such as
type: string, has no typed accessor. - The status has different body shapes. If the same status code has more than one body schema across the API, every exception for that status exposes
object.
Reading a property from Body stops compiling in these cases. Because the runtime type can vary, check the type instead of casting:
To fix: Where the compiler flags a Body read, check the type with is, such as ex.Body is string text for a plain-text body, and handle other types.
SDK identification headers aren’t sent
The SDK doesn’t send the X-Fern-Language, X-Fern-SDK-Name, or X-Fern-SDK-Version headers, and doesn’t send a User-Agent header in their place. Application code is unaffected, but server-side logging, routing, or firewall rules that depend on these headers stop matching.
To fix: Update server-side rules that depend on the previous headers, and set your own identifying header if you need one.
The project layout changes
The generated project is organized by kind (Clients/, Models/, Services/, Core/, Exceptions/, and Http/) instead of one folder per API group. Namespaces are mostly unchanged, so existing using directives keep working. A group’s client type may move to the root namespace, for example from PlantStore.Inventory.InventoryClient to PlantStore.InventoryClient. Accessing the group through client.Inventory is unchanged.
To fix: Update using directives that reference a moved client type, and update tooling that references generated files by path, such as file lists, per-file lint or coverage rules, and solution filters.
Failed POST and PATCH requests aren’t retried by default
Retries on 5xx, 408, and 429 responses are unchanged for GET, PUT, DELETE, HEAD, and OPTIONS. POST and PATCH requests are no longer retried unless you mark them as safe to repeat. A POST that times out might already have succeeded on the server, and retrying it could, for example, place the same order twice. The compiler doesn’t flag this, so a call that used to recover from a transient failure throws on the first failure instead.
To make a request retryable, send an idempotency key. The server uses it to recognize a repeated request and return the original result instead of acting twice:
Generate a new key for each request. If you set one key on ClientOptions or through SetConfig(...), the server treats two different requests as the same one and discards the second.
To fix: Nothing, unless you relied on a POST or PATCH recovering from a transient 5xx. For those calls, send a new idempotency key per request, and confirm that your API supports the header.
Some URL characters are escaped differently
Path and query values are URL-encoded slightly differently. Both forms are valid, and servers decode them to the same value. For a query value of a;b&c=d+e f/g?h#i:
In path segments, ; is escaped where it previously wasn’t. The change matters only when something compares the raw URL instead of the decoded value, such as a gateway rule that matches an exact string or a signing scheme that computes an HMAC over the raw query string.
To fix: If you sign requests over the raw URL or route on exact string matches, test those rules against a request from the new SDK before you roll it out.
Some endpoints take a request object
An endpoint that took its arguments positionally, or took no arguments, might take a single request object instead:
Unlike most changes on this page, this one is a compile error, so your build points to every affected call. It usually affects only a few endpoints.
Don’t assume the request object is empty. Most carry a value, such as a path parameter, a request body, or a required header like an account token (see Pass credentials by name). Only a few are empty and take new PlantsGetRequest(). Your IDE lists the required members.
To fix: Move each call’s arguments into the request object that the compiler names. Behavior doesn’t change; the same values reach the same endpoint.
Other changes
- Union accessors and type names may change. For example,
value.AsText()may becomevalue.AsContentText(), and a flat record may become a union. Check each regenerated type instead of assuming its shape is unchanged. - Generated tests are replaced. The test project keeps its name and location, but its contents are a new suite with tests per model and per API group instead of per endpoint. To turn off generated tests, set
generation.wireTests.enabled: falseinsdk-config.yml. When you migrate fromgenerators.yml,enable-wire-tests: falseis carried over, butgenerate-mock-server-tests: falseisn’t. - New configuration is available.
SetConfig(...)andSet{Method}Config(...)configure the client, andRequestOptionshas aRetrypolicy alongsideTimeout,MaxRetries, andBaseUrl. These additions don’t require code changes.