Modern .NET Development
Refit and OpenAPI: Generating Reliable .NET API Clients
A generated client removes repetitive HTTP plumbing. It does not remove the need to own the contract, classify failures, control retries or survive a server deployment the client did not coordinate.

The client library is part of the API contract
Refit turns an annotated C# interface into an HTTP client. OpenAPI generators turn a machine-readable API description into client code. Both eliminate hand-written request construction, but they begin from different sources of truth. With Refit, the consumer interface is usually written deliberately. With OpenAPI generation, the provider's published description drives the shape.
The architectural question is not which tool writes fewer lines. It is who owns compatibility when the server, schema, generator and application release independently. Generated code can be perfectly consistent with an inaccurate document. A Refit interface can compile while sending the wrong media type or treating every non-success response as the same failure.
Treat the client as a boundary adapter. Application code should depend on operations and outcomes it understands, not on generator-specific response types throughout the codebase.
Choose the source of truth explicitly
| Approach | Strength | Hidden cost |
|---|---|---|
Hand-written HttpClient | Full protocol control | Repeated serialisation, headers and error handling |
| Hand-written Refit interface | Small, readable consumer contract | Can drift from provider documentation |
| OpenAPI-generated client | Broad schema and operation coverage | Large generated surface and generator churn |
| Provider-owned SDK | Curated consumer experience | Provider owns releases, support and language ecosystem |
For a small internal integration, a curated Refit interface often exposes only what the consumer uses and keeps change review readable. For a large public surface, generation can provide comprehensive models and reduce manual drift. A provider-owned SDK is justified when authentication, pagination, retries or workflows need a supported abstraction above raw endpoints.

OpenAPI generation reproduces omissions faithfully
OpenAPI describes paths, parameters, schemas and documented responses. It rarely captures the whole behavioural contract: idempotency-key retention, cursor stability, eventual consistency, unknown enum handling, retry policy or the meaning of a 409 code. If these behaviours matter, document them alongside the schema and wrap them in the SDK where appropriate.
Generate the document in CI and diff it before release. A removed property or newly required parameter should fail a compatibility gate. Do not accept a noisy generated diff that nobody can review; pin generator and package versions, normalise unstable output and upgrade tooling as a deliberate change.
Operation IDs become method names in many generators. Treat them as public identifiers. Route names based on implementation details create ugly, unstable SDKs even when the HTTP route itself remains compatible.
Do not edit generated code
Generated files should be reproducible artifacts. Manual fixes disappear on regeneration and conceal missing information in the source contract. Extend clients with partial classes when the generator supports them, delegation handlers, wrapper services or a separate adapter project. Fix provider metadata at its source.
Keep generation deterministic: record the input document, tool version, settings and command. Decide whether generated code is committed or built in CI. Committing makes diffs and downstream builds easier but requires a check that regeneration produces no changes. Build-time-only generation avoids repository noise but makes tooling availability part of every build.
Refit signatures must reflect wire behaviour
A method returning Task<Order> is convenient when every success body is an order and every failure may throw. Task<ApiResponse<Order>> is useful when application logic needs status, headers and structured failure content. Do not choose the broader type everywhere merely to avoid exceptions; it spreads transport decisions into business code.
Use cancellation tokens on every potentially long operation. Model route, query, header and body parameters explicitly. Be deliberate about serializer settings, property naming, dates and enums. Sharing DTO assemblies between server and client can hide wire compatibility changes because both compile together; independent contract types and serialization tests reveal the real boundary.
Never place access tokens in interface parameters used by callers. Use a message handler or token provider so credential attachment, renewal and redaction are centralised.
Translate HTTP failures once
Refit exposes ApiException and response information, but application services should not each parse the body and invent their own rules. A client adapter can translate stable Problem Details codes into a small set of outcomes: not found, validation rejected, conflict, unauthorised, throttled, transient failure and unexpected contract failure.
Preserve diagnostic context without leaking bodies or tokens. Record operation, status, error code, trace identifier and duration. Deserialisation failure on a 200 response is a contract incident, not a transient network error; retrying the same incompatible representation only adds load.
Retries belong to operations, not clients
A blanket retry handler around every Refit method is dangerous. GET can usually be retried after transient transport failure. POST may have committed before the response was lost. Retry it only when the API supplies idempotency semantics or the operation itself is safe to repeat.
Use bounded exponential backoff with jitter, honour server retry guidance and keep total timeout budgets explicit. An individual attempt timeout multiplied by retries can exceed the user request or worker lease that initiated the call. Propagate cancellation and distinguish caller cancellation from dependency timeout in telemetry.
Retries, circuit breaking and concurrency limits protect different things. A circuit stops repeatedly calling a failing dependency; it does not make an operation reliable. A concurrency limit prevents one dependency consuming every outbound connection. Test policies against representative failure sequences rather than assuming defaults are safe.
HttpClient lifetime still matters
Refit ultimately uses HttpClient. Register typed clients through the HTTP client factory or use a deliberately managed handler lifetime. Creating and disposing a new underlying handler for each call can exhaust sockets; keeping one handler forever can ignore DNS changes. The factory balances connection pooling and handler rotation.
Keep handler order visible. Authentication, correlation, resilience and logging handlers interact: retries must obtain a usable token, and telemetry should show attempts without counting one logical operation as several user actions. Redact sensitive headers before any HTTP logging.
Downloads and streaming need their own contract
A schema that calls a response “binary” does not decide whether a generated client buffers it into memory. Large files should stream from response to destination, with response headers available before body consumption. The caller must own disposal of the response and stream.
Define filename, media type, length, range support, checksums and error bodies. A failed download may return Problem Details instead of binary content. Inspect status and media type before copying bytes, and never save a server-supplied filename without sanitising it.
Client releases need compatibility tests
- Generate from the checked-in or published OpenAPI document and fail on unapproved diffs.
- Run old supported clients against the new API.
- Run the new client against old server instances that may exist during rollout.
- Test unknown enum values and additional response properties.
- Test every documented error shape through the actual client serializer.
- Simulate timeout after commit and verify retry behaviour.
- Stream a representative large download while measuring memory.
- Verify tokens and bodies are absent from logs.
A mock handler proves request construction but not gateway behaviour, TLS, redirects, compression or actual JSON settings. Keep fast unit tests and add a small number of integration tests against a real hosted API.
A reliable client boundary
Expose a narrow application-facing interface, keep generated or Refit transport code behind it, and translate protocol failures centrally. Pin generation, own OpenAPI quality, treat SDK names and error codes as compatibility surfaces, and apply resilience by operation semantics.
The best client is not the one that hides HTTP completely. It is the one that makes important HTTP behaviour hard to misuse while preserving enough evidence to diagnose a broken contract.
Related C3 Software guides
Frequently asked questions
Is Refit the same as generating a client from OpenAPI?
No. Refit generates runtime implementation from a C# interface. OpenAPI tools usually generate C# interfaces, models and implementations from an API document.
Should generated client code be committed?
Either approach works if generation is deterministic. Committed output improves review and consumer builds; build-time output reduces repository noise but makes the toolchain part of every build.
Should every Refit call return ApiResponse?
No. Use it where callers need status or headers. Otherwise translate transport details in an adapter and expose a simpler application outcome.
Can all API calls be retried?
No. Retry only transient failures and operations that are idempotent or protected by an idempotency mechanism.
Build .NET integrations that survive contract change
C3 Software Limited has built and supported business software in the UK since 2001. If generated clients are drifting from the APIs they call, talk to C3 Software about an integration or contract review.